|
| 1 | +# HTMY |
| 2 | + |
| 3 | +The primary focus of this example is how to create [htmy](https://volfpeter.github.io/htmy/) components that work together with `fasthx` and make use of its utilities. The components use TailwindCSS for styling -- if you are not familiar with TailwindCSS, just ignore the `class_="..."` arguments, they are not important from the perspective of `fasthx` and `htmy`. The focus should be on the [htmy](https://volfpeter.github.io/htmy/) components, context usage, and route decorators. |
| 4 | + |
| 5 | +First, let's create an `htmy_app.py` file, import everything that is required for the example, and also define a simple Pydantic `User` model for the application: |
| 6 | + |
| 7 | +```python |
| 8 | +import random |
| 9 | +from dataclasses import dataclass |
| 10 | +from datetime import date |
| 11 | + |
| 12 | +from fastapi import FastAPI |
| 13 | +from htmy import Component, Context, html |
| 14 | +from pydantic import BaseModel |
| 15 | + |
| 16 | +from fasthx.htmy import HTMY, ComponentHeader, CurrentRequest, RouteParams |
| 17 | + |
| 18 | + |
| 19 | +class User(BaseModel): |
| 20 | + """User model.""" |
| 21 | + |
| 22 | + name: str |
| 23 | + birthday: date |
| 24 | +``` |
| 25 | + |
| 26 | +The main content on the user interface will be a user list, so let's start by creating a simple `UserListItem` component: |
| 27 | + |
| 28 | +```python |
| 29 | +@dataclass |
| 30 | +class UserListItem: |
| 31 | + """User list item component.""" |
| 32 | + |
| 33 | + user: User |
| 34 | + |
| 35 | + def htmy(self, context: Context) -> Component: |
| 36 | + return html.li( |
| 37 | + html.span(self.user.name, class_="font-semibold"), |
| 38 | + html.em(f" (born {self.user.birthday.isoformat()})"), |
| 39 | + class_="text-lg", |
| 40 | + ) |
| 41 | +``` |
| 42 | + |
| 43 | +As you can see, the component has a single `user` property and it renders an `<li>` HTML element with the user's name and birthday in it. |
| 44 | + |
| 45 | +The next component we need is the user list itself. This is going to be the most complex part of the example: |
| 46 | + |
| 47 | +- To showcase `htmy` context usage, this component will display some information about the application's state in addition to the list of users. |
| 48 | +- We will also add a bit of [HTMX](https://htmx.org/attributes/hx-trigger/) to the component to make it re-render every second. |
| 49 | + |
| 50 | +```python |
| 51 | +@dataclass |
| 52 | +class UserOverview: |
| 53 | + """ |
| 54 | + Component that shows a user list and some additional info about the application's state. |
| 55 | +
|
| 56 | + The component reloads itself every second. |
| 57 | + """ |
| 58 | + |
| 59 | + users: list[User] |
| 60 | + ordered: bool = False |
| 61 | + |
| 62 | + def htmy(self, context: Context) -> Component: |
| 63 | + # Load the current request from the context. |
| 64 | + request = CurrentRequest.from_context(context) |
| 65 | + # Load route parameters (resolved dependencies) from the context. |
| 66 | + route_params = RouteParams.from_context(context) |
| 67 | + # Get the user-agent from the context which is added by a request processor. |
| 68 | + user_agent: str = context["user-agent"] |
| 69 | + # Get the rerenders query parameter from the route parameters. |
| 70 | + rerenders: int = route_params["rerenders"] |
| 71 | + |
| 72 | + # Create the user list item generator. |
| 73 | + user_list_items = (UserListItem(u) for u in self.users) |
| 74 | + |
| 75 | + # Create the ordered or unordered user list. |
| 76 | + user_list = ( |
| 77 | + html.ol(*user_list_items, class_="list-decimal list-inside") |
| 78 | + if self.ordered |
| 79 | + else html.ul(*user_list_items, class_="list-disc list-inside") |
| 80 | + ) |
| 81 | + |
| 82 | + # Randomly decide whether an ordered or unordered list should be rendered next. |
| 83 | + next_variant = random.choice(("ordered", "unordered")) # noqa: S311 |
| 84 | + |
| 85 | + return html.div( |
| 86 | + # -- Some content about the application state. |
| 87 | + html.p(html.span("Last request: ", class_="font-semibold"), str(request.url)), |
| 88 | + html.p(html.span("User agent: ", class_="font-semibold"), user_agent), |
| 89 | + html.p(html.span("Re-renders: ", class_="font-semibold"), str(rerenders)), |
| 90 | + html.hr(), |
| 91 | + # -- User list. |
| 92 | + user_list, |
| 93 | + # -- HTMX directives. |
| 94 | + hx_trigger="load delay:1000", |
| 95 | + hx_get=f"/users?rerenders={rerenders+1}", |
| 96 | + hx_swap="outerHTML", |
| 97 | + # Send the next component variant in an X-Component header. |
| 98 | + hx_headers=f'{{"X-Component": "{next_variant}"}}', |
| 99 | + # -- Styling |
| 100 | + class_="flex flex-col gap-4", |
| 101 | + ) |
| 102 | +``` |
| 103 | + |
| 104 | +Most of this code is basic Python and `htmy` usage (including the `hx_*` `HTMX` attributes). The important, `fasthx`-specific things that require special attention are: |
| 105 | + |
| 106 | +- The use of `CurrentRequest.from_context()` to get access to the current `fastapi.Request` instance. |
| 107 | +- The use of `RouteParams.from_context()` to get access to every route parameter (resolved FastAPI dependency) as a mapping. |
| 108 | +- The `context["user-agent"]` lookup that accesses a value from the context which will be added by a _request processor_ later in the example. |
| 109 | + |
| 110 | +We need one last `htmy` component, the index page. Most of this component is just the basic HTML document structure with some TailwindCSS styling and metadata. There is also a bit of `HTMX` in the `body` for lazy loading the actual page content, the user list we just created. |
| 111 | + |
| 112 | +```python |
| 113 | +@dataclass |
| 114 | +class IndexPage: |
| 115 | + """Index page with TailwindCSS styling.""" |
| 116 | + |
| 117 | + def htmy(self, context: Context) -> Component: |
| 118 | + return ( |
| 119 | + html.DOCTYPE.html, |
| 120 | + html.html( |
| 121 | + html.head( |
| 122 | + # Some metadata |
| 123 | + html.title("FastHX + HTMY example"), |
| 124 | + html.meta.charset(), |
| 125 | + html.meta.viewport(), |
| 126 | + # TailwindCSS |
| 127 | + html.script(src="https://cdn.tailwindcss.com"), |
| 128 | + # HTMX |
| 129 | + html.script( src="https://unpkg.com/[email protected]"), |
| 130 | + ), |
| 131 | + html.body( |
| 132 | + # Page content: lazy-loaded user list. |
| 133 | + html.div(hx_get="/users", hx_trigger="load", hx_swap="outerHTML"), |
| 134 | + class_=( |
| 135 | + "h-screen w-screen flex flex-col items-center justify-center " |
| 136 | + " gap-4 bg-slate-800 text-white" |
| 137 | + ), |
| 138 | + ), |
| 139 | + ), |
| 140 | + ) |
| 141 | +``` |
| 142 | + |
| 143 | +With all the components ready, we can now create the `FastAPI` and `fasthx.htmy.HTMY` instances: |
| 144 | + |
| 145 | +```python |
| 146 | +# Create the app instance. |
| 147 | +app = FastAPI() |
| 148 | + |
| 149 | +# Create the FastHX HTMY instance that renders all route results. |
| 150 | +htmy = HTMY( |
| 151 | + # Register a request processor that adds a user-agent key to the htmy context. |
| 152 | + request_processors=[ |
| 153 | + lambda request: {"user-agent": request.headers.get("user-agent")}, |
| 154 | + ] |
| 155 | +) |
| 156 | +``` |
| 157 | + |
| 158 | +Note how we added a _request processor_ function to the `HTMY` instance that takes the current FastAPI `Request` and returns a context mapping that is merged into the `htmy` rendering context and made available to every component. |
| 159 | + |
| 160 | +All that remains now is the routing. We need two routes: one that serves the index page, and one that renders the ordered or unordered user list. |
| 161 | + |
| 162 | +The index page route is trivial. The `htmy.page()` decorator expects a component factory (well more precisely a `fasthx.ComponentSelector`) that accepts the route's return value and returns an `htmy` component. Since `IndexPage` has no properties, we use a simple `lambda` to create such a function: |
| 163 | + |
| 164 | +```python |
| 165 | +@app.get("/") |
| 166 | +@htmy.page(lambda _: IndexPage()) |
| 167 | +def index() -> None: |
| 168 | + """The index page of the application.""" |
| 169 | + ... |
| 170 | +``` |
| 171 | + |
| 172 | +The `/users` route is a bit more complex: we need to use the `fasthx.htmy.ComponentHeader` utility, because depending on the value of the `X-Component` header (remember the `hx_headers` declaration in `UserOverview.htmy()`) it must render the route's result either with the ordered or unordered version of `UserOverview`. |
| 173 | + |
| 174 | +The route also has a `rerenders` query parameter just to showcase how `fasthx` makes resolved route dependencies accessible to components through the `htmy` rendering context (see `UserOverview.htmy()` for details). |
| 175 | + |
| 176 | +The full route declaration is as follows: |
| 177 | + |
| 178 | +```python |
| 179 | +@app.get("/users") |
| 180 | +@htmy.hx( |
| 181 | + # Use a header-based component selector that can serve ordered or |
| 182 | + # unordered user lists, depending on what the client requests. |
| 183 | + ComponentHeader( |
| 184 | + "X-Component", |
| 185 | + { |
| 186 | + "ordered": lambda users: UserOverview(users, True), |
| 187 | + "unordered": UserOverview, |
| 188 | + }, |
| 189 | + default=UserOverview, |
| 190 | + ) |
| 191 | +) |
| 192 | +def get_users(rerenders: int = 0) -> list[User]: |
| 193 | + """Returns the list of users in random order.""" |
| 194 | + result = [ |
| 195 | + User(name="John", birthday=date(1940, 10, 9)), |
| 196 | + User(name="Paul", birthday=date(1942, 6, 18)), |
| 197 | + User(name="George", birthday=date(1943, 2, 25)), |
| 198 | + User(name="Ringo", birthday=date(1940, 7, 7)), |
| 199 | + ] |
| 200 | + random.shuffle(result) |
| 201 | + return result |
| 202 | +``` |
| 203 | + |
| 204 | +We finally have everything, all that remains is running our application. Depending on how you [installed FastAPI](https://fastapi.tiangolo.com/#installation), you can do this for example with: |
| 205 | + |
| 206 | +- the `fastapi` CLI like this: `fastapi dev htmy_app.py`, |
| 207 | +- or with `uvicorn` like this: `uvicorn htmy_app:app --reload`. |
| 208 | + |
| 209 | +If everything went well, the application will be available at `http://127.0.0.1:8000`. |
0 commit comments