def get_app():
return (app := nb_app()), TestClient(app, base_url='http://nb', headers={'hx-request': '1'}), app.route
app, cli, rt = get_app()Routes provider
APIRouter capabilities.
patched
Quick hack to monkey-patch a module function.
FastHTML only considers as of now (v.0.12.0) names of static (possibly nested) functions. We’ll need to temporarily patch nested_name to get name of methods (see APIRouterD below).
Methods as routes endpoints
Understanding FastHTML’s routing system and extending it to support instance methods.
In this docs I use widget/component interchangeably.
Why Method-Based Routes in Notebooks?
FastHTML typically encourages defining components in separate modules. While this works great for web applications, notebooks have different workflows:
Interactive Development
Notebooks are all about exploration and rapid prototyping. Everything happens in cells - they’re our unit of work. Notebooks are more akin to one page apps. When you’re quickly testing ideas or building one-time components, creating separate modules can feel like unnecessary overhead. Method-based routes let us define and test components right where we need them, with scoping and encapsulation.
State Management
When building widgets, we often need to maintain state. Instance methods make this natural. While both modules and classes can handle state effectively, sometimes one approach fits better than the other. It’s nice to have options.
Quick Iteration
The real advantage comes when you’re iterating on a component. You can write code, test it, and modify it all in the same context. Make a change, run the cell, see the results. No jumping between files or reloading modules needed.
This approach may complement FastHTML’s module-based components by providing another option for route management.
Scoping
All this discussion is for current FastHTML, v0.12.0 at the time of this writing. FastHTML is young and nervous, so things may change quickly and unexpectedly.
app, cli, rt = get_app()
def a(): return 'a' # type: ignore
test_eq(type(a), FunctionType)
test_eq(str(a), f"<function a at 0x{id(a):x}>")
global_a = a
# @rt('/a', 'get')
# def a(): return 'a'
# or its nearly equivalent:
a = rt('/a', 'get')(a)
assert a is not global_a
test_eq(str(a), '/a')
test_eq(cli.get('/a').text, 'a')
test_eq(app.url_path_for('a'), '/a')
test_eq(a.to(), '/a')
test_eq(set(app.routes[-1].methods), {'GET', 'HEAD'}) # type: ignore
@rt('/a', 'post')
def a(x:int): return f'a {x}'
test_eq(cli.post('/a?x=5').text, 'a 5')
test_eq(set(app.routes[-1].methods), {'POST'}) # type: ignoreFastHTML.route decorator, or the common rt alias, creates a Starlette Route with an endpoint based on a static function, function a in this case. But it’s not function a, it’s a especial callable wrapped over the function a to facilitate route introspection.
During route definition, a is removed from the scope it was defined and can be used to create additional routes with other HTTP methods (with caveats, 'post' is a especial case).
def b():
@rt('/b')
def _b(): return 'b'
return _b
innerb = b()
test_eq(cli.get('/b').text, 'b')
test_eq(str(innerb), '/b')
test_eq(app.url_path_for('b__b'), '/b')
test_eq(innerb.to(), '/b')Recent FastHTML versions also supports nested functions on local scopes.
ar = APIRouter()
@ar('/c')
def c(): return 'c'
test_eq(str(c), '/c')
test_eq(ar.c.to(), '/c')
test_eq(ar.rt_funcs.c.to(), '/c')
ar.to_app(app)
test_eq(cli.get('/c').text, 'c')
test_eq(app.url_path_for('c'), '/c')APIRouter is a convenient way of grouping routes.
FastHTML’s Route Requirements
FastHTML’s routing system builds on Starlette’s, which accepts both functions and methods as endpoints (besides ASGI classes). The only requirement is being a callable:
endpoint: typing.Callable[..., typing.Any]FastHTML follows FastAPI’s style of using decorators for route definition. Route endpoints must be:
1. Callables with a __name__ (__qualname__ as of v0.12.0) attribute (for route identification) FastHTML._add_route
2. Have type annotations for all parameters (for request validation)
While this works for functions, it creates challenges with instance methods:
- Method Binding: When using decorators at class level, methods are still unbound functions:
- The decorator sees the function
get, not the bound method - The
selfparameter won’t be properly valued
- The decorator sees the function
- Type Annotations: FastHTML expects all parameters to be annotated:
- The implicit
selfparameter lacks annotation - This triggers warnings in FastHTML’s validation
- The implicit
APIRouter like rt only works wth static functions.
As a workaround we could use partial (or FastHTML own trick, a lightweight stateful callable) to bind methods after instance creation. As partials don’t have a __name__ (__qualname__ now) attribute, we need to hack it manually:
class AClass:
def get(self, a:str): return f'{self!r} {a!r}'
a = AClass()
# Manually create bound method and set name
(f := partial(a.get.__func__, a)).__qualname__ = 'get' # type: ignore
rt('/give/me/{a}')(f)
cli.get('/give/me/b').text"<__main__.AClass object at 0x148080e60> 'b'"
This approach is verbose and error-prone, separates route definition from method implementation, and makes code harder to maintain, besides being cumbersome and ugly. We need something more in order to use methods as endpoints with fastHTML. It´s clearly time for some more python dark magic here. But we’re in python-land where magic abounds.
We’ll extend FastHTML’s routing capabilities by:
- Creating an enhanced APIRouter class that support (and preserves) method references
- Supporting automatic method binding at instance creation
- Enabling property-based routing for GET/POST/PUT/DELETE operations
- Providing automatic route mounting and path generation
We aim for a more natural and maintainable syntax:
class Widget:
...
@ar
def value(self):
return self._valueWe’ll use the handy APIRouter to define routes providers.
Most of this notebook was written before v0.12.0. In v0.9.1, APIRouter was defined but not used anywhere. I wasn’t sure Jeremy’s intention for the class, but it seemed appropiate using it here for our purposes.
From v0.10.2 to v0.12.0, APIRouter has evolved considerably but the basic idea proposed here holds through.
First, some helpers to get the routes from a provider and its class hierarchy.
def routes_from(o: object):
"Yield all route descriptors (path,methods,name,include_in_schema,body_wrap) from class hierarchy in mro order"
for base in (o if isinstance(o, type) else type(o)).mro()[:-1]:
if not isinstance(base, type) or not hasattr(base, 'ar'): continue
yield from base.ar.routes
def add_routes(app: FastHTML, o):
for f,p,m,n,*_ in routes_from(o):
app.add_route(Route(p, f, methods=m, name=n))routes_from returns the routes in MRO order. If we add the routes in that order, Starlette will match them in that order too (NOTE: check this is documented). As such, more specific endpoints have precedence over more generic ones.
Starlette accepts happily methods as endpoints, but as we’ve seen, not current FastHTML versions (0.12.0 as of this writing). In FastHTML, we need to use bound functions – AKA methods–. And that is a bit more involved than using app.route on static functions.
class A:
ar = APIRouter()
@ar('/a')
def a(self): return 'a'
class B(A):
ar = APIRouter()
@ar('/b')
def b(self): return 'b'
class C(B):
ar = APIRouter()
@ar('/c?v={v}')
def c(self, v:int=0): return f'c {v}'
class D(A):
ar = APIRouter()
@ar('/d')
def d(self): return 'd'
class E(B, D):
ar = APIRouter()
@ar('/a')
def e(self): return 'e'
test_eq(C.mro()[:-1], [C, B, A])
test_eq(E.mro()[:-1], [E, B, D, A])
test_eq([_[1] for _ in list(routes_from(C))], ['/c?v={v}', '/b', '/a'])
test_eq([_[1] for _ in list(routes_from(E))], ['/a', '/b', '/d', '/a'])app, cli, _ = get_app()
add_routes(app, C)
test_eq([_.path for _ in app.routes], ['/c?v={v}', '/b', '/a']) # type: ignore
c_rt = app.routes[0]
match, _ = c_rt.matches({'type': 'http', 'path': '/c?v=3', 'method': 'GET'})
test_eq(match, Match.FULL)
match, _ = c_rt.matches({'type': 'http', 'path': '/c?v=7', 'method': 'PUT'})
test_eq(match, Match.PARTIAL)app, cli, _ = get_app()
add_routes(app, E)
test_eq([_.path for _ in app.routes], ['/a', '/b', '/d', '/a']) # type: ignore
e_rt = app.routes[0]
match, child_scope = e_rt.matches({'type': 'http', 'path': '/a', 'method': 'GET'})
test_eq(match, Match.FULL)
test_eq(child_scope['endpoint'], e_rt.endpoint) # type: ignore
test_eq(e_rt.endpoint(''), 'e') # type: ignoreRouteProvider
Any object with an
APIRouterattribute can be a provider of routes.
RouteProvider
RouteProvider ()
Base class for objects that provide routes via an APIRouter
RouteProviderP
RouteProviderP (*args, **kwargs)
A provider of routes
APIRouterD
APIRouter as a descriptor
APIRouter for Method-Based Routes.
Extends FastHTML’s APIRouter to preserve method references while enabling route registration.
For reasons unclear to me, APIRouter wipes out the functions when defining routes unlike normal @rt decorator.
We need to keep the functions around in order for the class to get its methods.
APIRouterD preserves the original method references while collecting routes. This allows methods to work both as routes and methods, with proper instance binding. Like FastHTML’s standard APIRouter, routes are stored but not registered immediately, which lets us bind them to instances at mount time. This delayed registration enables stateful components and works naturally with Python properties and methods, maintaining compatibility with all FastHTML’s routing features.
test_eq(_replace(''), '')
test_eq(_replace('a.b'), 'a_b')
test_eq(_replace('/', sep='/'), '')
test_eq(_replace('/a', sep='/'), 'a')
test_eq(_replace('/a/', sep='/'), 'a')
test_eq(_replace('/a/b', sep='/'), 'a_b')def nested_name(f):
"Get name of function/method `f` using rep to join nested function names"
return _replace(f.__qualname__.replace('.<locals>.', '_'))def f():
def ff(): return 'ff'
return ff
test_eq(nested_name(f), 'f')
test_eq(nested_name(f()), 'f_ff')
class A:
def f(self):
def ff(): return 'ff'
return ff
test_eq(nested_name(A.f), 'A_f')
test_eq(nested_name(A.f(A())), 'A_f_ff')class A:
def f(self):
def ff(): return 'ff'
return ff
with _patched(fasthtml.core, 'nested_name', nested_name):
test_eq(fasthtml.core.nested_name(A.f), 'A_f')
test_eq(fasthtml.core.nested_name(A.f(A())), 'A_f_ff')We monkey-patch temporarily the core module to change its nested_name function with ours.
I think the original intuition of Jeremy with APIRouter was correct: a simple class that gather the routes arguments and defined the final routes in to_app. But as it removed the functions from the definition scope, it was not possible to inspect the routes with usual to() or reverse lookup URLs.
Current version solves the issue and also adds to APIRouter a rt_funcs property with the (non HTTP methods) routes.
APIRouterC
fasthtml.core.nested_name only considers static (possibly nested) functions.
Current APIRouter (v0.12.0) has a minor issue in _wrap_func, it uses __name__ for the name of the route instead of __qualname__. I could patch it, but this subclass will do for now.
APIRouterC also has an alternative to rt_funcs, given the method name: - APIRouterC.to(name, …) return the full route path. - APIRouterC.name(name) return the full route name (if name was provided when adding the APIRouter). - can use HTTP methods as method names (though why?)
APIRouterD
APIRouterD
APIRouterD (*args, **kwargs)
Add routes to an app
class A:
ar = APIRouterD()
def __init__(self, value:int=3): self.value = value
@ar # path /, name A_index
def index(self):
# int 0 is not a valid FastHTML return value
return self.value if self.value != 0 else HTMLResponse('0')
@ar # or @ar.get or @ar.get('/a')
def a(self): return f'a {self.value}'
@ar.post('/a')
def a_change(self, x:int): self.value = x; return f'new value {x}'
@ar('/b') # name A_b, , method [GET, POST]
def b(self): return f'b {self.value}'
@ar
@staticmethod
def c(): return 'c'
@ar
@classmethod
def d(cls): return f'd {cls.__name__}'
@ar
@property # path /e, name A_e, method GET
def e(self): return f'e {self.value}' # type: ignore
@ar
@e.setter # path /e, name A_e, method [POST, PUT]
def e(self, x:int): self.value = x; return x # type: ignore
@ar # path /e, name A_e, method DELETE
@e.deleter
def e(self): del self.value; return Response(status_code=204)
@ar('/f')
def get(self): return 'get'a = A()
test_eq(a.b(), 'b 3')
test_is('bound' in str(a.a), True)
test_eq(A.ar.to('d'), '/d')
test_eq(A.ar.to('a', x=4), '/a?x=4')app, cli, _ = get_app()
a = A()
a.ar.to_app(app)app.routes[Route(path='/', name='index', methods=['GET', 'HEAD']),
Route(path='/a', name='a', methods=['GET', 'HEAD']),
Route(path='/a', name='a_change', methods=['POST']),
Route(path='/b', name='b', methods=['GET', 'HEAD']),
Route(path='/c', name='c', methods=['GET', 'HEAD']),
Route(path='/d', name='d', methods=['GET', 'HEAD']),
Route(path='/e', name='e', methods=['GET', 'HEAD']),
Route(path='/e', name='e', methods=['POST', 'PUT']),
Route(path='/e', name='e', methods=['DELETE']),
Route(path='/f', name='get', methods=['GET', 'HEAD'])]
a is a normal instance of A.
A.ar is an instance of APIRouterD, a subclass of APIRouterC. It has basic route instrospection (.to, .name), but its really a descriptor. Its main function is storing the routes precursors like APIRouter does. Once the instance a is created, it assigns a.ar with a normal APIRouterC. When ar.to_app(app) is called, the final route endpoints are binded to the instance and installed.
Note that the APIRouter workflow setups the endpoints before calling to_app(app), endpoints are static functions. APIRouterC delays that setup until the very last moment, when to_app(app) is called. This lazy binding allows adding different routes in distinct scopes (see Adding routes to the app, add_routes() and mount()).
test_eq(cli.get('/').text, '3')
test_eq(cli.get('/a').text, 'a 3')
test_eq(cli.post('/a?x=5').text, 'new value 5')
test_eq(a.value, 5)
test_eq(cli.get('/b').text, 'b 5')
test_eq(cli.get('/c').text, 'c')
test_eq(cli.get('/d').text, 'd A')
test_eq(cli.get('/e').text, 'e 5')
test_eq(cli.post('/e?x=7').text, '7')
test_eq(a.value, 7)
test_eq(str(cli.delete('/e')), '<Response [204 No Content]>')
a.value = 0
test_eq(cli.get('/').text, '0')
test_eq(cli.get('/f').text, 'get')test_eq(app.url_path_for('a'), '/a')
test_eq(app.url_path_for(a.ar.name('a')), '/a')
test_eq(str(a.ar.a), '/a')
test_eq(a.ar.a.to(), '/a')
test_eq(a.ar.to('a'), '/a')
test_eq(set([*filter(lambda r: r.path == '/a', app.routes)][0].methods), {'GET', 'HEAD'}) # type: ignoreNote that ar can also setup properties as routes. Use as above, or simply once on the last descriptor function.
class A:
ar = APIRouterD()
def __init__(self, value:int=3): self.value = value
@property
def e(self): return f'e {self.value}' # type: ignore
@e.setter
def e(self, x:int): self.value = x; return x # type: ignore
@ar
@e.deleter
def e(self): del self.value; return Response(status_code=204)
app, cli, _ = get_app()
a = A()
a.ar.to_app(app)
test_eq(cli.post('/e?x=3').text, '3')
test_eq(a.value, 3)
test_eq(str(cli.delete('/e')), '<Response [204 No Content]>')test_eq(A.ar.to(), '')
test_eq(A.ar.to('e'), '/e')
with ExceptionExpected(AttributeError): A.ar.a
with ExceptionExpected(AttributeError): A.ar.rt_funcs.a
with ExceptionExpected(TypeError): A.ar.to_app(app) # APIRouterD is not a valid APIRouterNote that the APIRouterD of the class, A.ar in the example above, is not an APIRouter, should be used only for basic route inspection. The one on the instance, a.ar, is the one which actually can register the routes and is, in fact, a normal APIRouter.
An APIRouterD routeis very alike FastHTML.route or APIRouter’s. But as its underlying endpoint is a method/property, it has some different behaviors:
route names
app, cli, rt = get_app()
@rt('/a')
def a(): return 'a'
print(f"{'route from a ->':>20}", app.routes[-1])
test_eq(str(a), '/a')
test_eq(a.to(), app.url_path_for('a'))
test_eq(a.__routename__, 'a')
def b():
@rt('/a')
def c(): return 'c'
return c
c = b()
print(f"{'route from c ->':>20}", app.routes[-1])
test_eq(str(c), '/a')
test_eq(c.to(), app.url_path_for('b_c'))
test_eq(c.__routename__, 'b_c')
class D:
ar = APIRouterD('/D')
@ar('/a')
def d(self): return 'd'
d = D()
d.ar.to_app(app)
print(app.routes)
print(f"{'route from d.d ->':>20}", app.routes[-1])
test_eq(str(d.ar.d), '/D/a')
test_eq(d.ar.d.to(), app.url_path_for('d'))
test_eq(d.ar.d.__routename__, 'd')
test_eq(d.ar.to('d'), d.ar.rt_funcs.d.to())
test_eq(d.ar.name('d'), 'd') route from a -> Route(path='/a', name='a', methods=['GET', 'HEAD', 'POST'])
route from c -> Route(path='/a', name='b_c', methods=['GET', 'HEAD', 'POST'])
[Route(path='/a', name='a', methods=['GET', 'HEAD', 'POST']), Route(path='/a', name='b_c', methods=['GET', 'HEAD', 'POST']), Route(path='/D/a', name='d', methods=['GET', 'HEAD'])]
route from d.d -> Route(path='/D/a', name='d', methods=['GET', 'HEAD'])
- route name is the method name,
D_ain the example above, notd. - default method is ‘get’.
- Route Introspection: Methods don´t get the
.toattribute, like the wrappers used in routes. But those wrappers are, discoverable through.ar(e.g.d.ar.d.to()ord.ar.to('d')). - unlike
@rt/APIRouter, it doesn’t wipe out (see _add_route) the underlying function from the scope (class def) for obvious reasons– base methods would probably be ok, properties not. Python classes can’t have methods with the same name– dicts, you know.
If you want to handle different HTTP methods, you must use different func names or set them explcitly with the route arguments and modify the function to handle the distinct requests (args or explicit request arg).
Last point is a design decission. We could use metaclasses like fastcore transforms to introduce methods overriden by HTTP methods and in theory _mk_locfunc wrappers could function with methods (probably, not tested much). But I think a descriptor is already enough python dark magic for today :)
Inheritance
As usual with OOP and classes, let’s say its complicated.
class A:
ar = APIRouterD()
_value:str = 'a'
@ar
def value(self): return self._value
class B(A):
ar = APIRouterD()
_value: int = 1
@ar
def value(self): return self._value
app, cli, _ = get_app()
(a := A()).ar.to_app(app)
(b := B()).ar.to_app(app)
print(app.routes)
test_eq(str(a.ar.value), '/value')
test_eq(str(b.ar.value), '/value')
test_eq(cli.get('/value').text, '1') # a not accesible, overriden by b[Route(path='/value', name='value', methods=['GET', 'HEAD'])]
Basic inheritance, B.value() supercedes A.value().
To be able to access both objects, we need to use different routes for each object using prefix.
class A:
ar = APIRouterD('/A')
_value:str = 'a'
@ar
def value(self): return self._value
class B(A):
ar = APIRouterD('/B')
_value: int = 1
@ar
def value(self): return self._value
app, cli, _ = get_app()
(a := A()).ar.to_app(app)
(b := B()).ar.to_app(app)
print(app.routes)
test_eq(str(b.ar.value), '/B/value')
test_eq(cli.get('/A/value').text, 'a')
test_eq(cli.get('/B/value').text, '1')[Route(path='/A/value', name='value', methods=['GET', 'HEAD']), Route(path='/B/value', name='value', methods=['GET', 'HEAD'])]
But we can easily complicate things by using e.g, abstracts or mixins. Consider this contrived example.
class A_mixin:
ar = APIRouterD()
_value:int
@ar
def value(self): return self._value
class B(A_mixin):
ar = APIRouterD()
@ar.post
def value(self, x:int): self._value = x
@dataclass
class C(B):
ar = APIRouterD()
_value:int = 1
app, cli, _ = get_app()
(b := B()).ar.to_app(app)
(c := C()).ar.to_app(app)
print(app.routes)
test_eq(b.ar.value.to(), '/value') # but not accesible, overriden by c
test_eq(c.ar.value.to(), '/value')
# python fails
test_eq(c._value, 1)
with ExceptionExpected(TypeError): b.value() # type: ignore
with ExceptionExpected(TypeError): c.value() # type: ignore
# starlette has no problems
test_eq(cli.get('/value').text, '1') # <-- equivalent to A.value(c)
test_eq(cli.post(c.ar.value.to(x=5)).text, '') # <-- equivalent to B.value(c, x=5)
test_eq(c._value, 5)[Route(path='/value', name='value', methods=['POST']), Route(path='/value', name='value', methods=['GET', 'HEAD'])]
b is effectevely invisible to FastHTML Starlette.
Easy then, prefix each APIRouter like before.
class A_mixin:
ar = APIRouterD('/A')
_value:int
@ar
def value(self): return self._value
class B(A_mixin):
ar = APIRouterD('/AB')
@ar.post
def value(self, x:int): self._value = x
@dataclass
class C(B):
ar = APIRouterD('/ABC')
_value:int = 1
app, cli, _ = get_app()
(b := B()).ar.to_app(app)
(c := C()).ar.to_app(app)
print(app.routes)
test_eq(b.ar.value.to(), '/AB/value')
test_eq(c.ar.value.to(), '/ABC/value')
test_eq(c._value, 1)
with ExceptionExpected(TypeError): b.value() # type: ignore
with ExceptionExpected(TypeError): c.value() # type: ignore
with ExceptionExpected(AttributeError): cli.get('/AB/value').text
test_eq(cli.post(b.ar.value.to(x=3)).text, '')
test_eq(b._value, 3)
test_eq(cli.get('/AB/value').text, '3')
test_eq(cli.get('/ABC/value').text, '1') # <-- equivalent to A.value(c)
test_eq(cli.post(c.ar.value.to(x=5)).text, '') # <-- equivalent to B.value(c, x=5)
test_eq(c._value, 5)[Route(path='/AB/value', name='value', methods=['POST']), Route(path='/AB/value', name='value', methods=['GET', 'HEAD']), Route(path='/ABC/value', name='value', methods=['POST']), Route(path='/ABC/value', name='value', methods=['GET', 'HEAD'])]
In normal use, you probably won’t have to worry about this. Nevertheless you must be careful mixing inheritance and Starlette routing.
Method resolution in python follows MRO. Starlette routes lay in a flattened space, a list (or list of lists if sub-mounting). Unlike python, routes are matched in the order they are added by path, path parmas and HTTP methods. Unlike Starlette, FastHTML checks path and HTTP methods when adding routes, but the basic look-up remains the same.
In the contrived example above, python call fails, but Starlette has no problem finding the appropriate route because HTTP methds are different. A Route endpoint is a function, unbounded or, if using APIRouterD, bounded to the instance (of type C, not B or A, in the example). FastHTML adds extended functionality such as typing and introspection. The call works, but not in a pythonic way.
Without getting into Liskov and subtyping and the rest of OOP can of worms, think of APIRouterD as a way of organize routes and encapsulate state, nothing more. In Hypermedia systems, inheritance, classes, typing are ill defined concepts if at all. There’s not such thing as a “subclass” of a resource. Resources are uniquely idewntified with URIs, oprganized in hypermedia networks obtained with hypermedia controls. Semantics or behaviors are a thing of the server. The client only knows URIs.
So, stick with simple, flat scopes, and if you must, be very careful defining routes when inheritance is involved.
APIRoute
Convenience shortcut to avoid defining explictly
APIRouterDclass varar.
It´s annoying having to define explicitly an ar class var for each class. Coould we have a similar shortcut like rt in normal FastHTML?
APIRoute
APIRoute (path=None, methods=None, name=None, include_in_schema=True, body_wrap=<function noop_body>)
Initialize self. See help(type(self)) for accurate signature.
ar = APIRouteclass TestAR:
def __init__(self, a): self.a = a
@APIRoute('/hi')
@staticmethod
def hi(): return f'hi there'
@ar('/yoyo')
def yoyo(self):
return f'a yoyo called {self.a}'
@ar
def foo(self):
return f'foo {self.a}'
t = TestAR('yo')
test_eq(t.yoyo(), 'a yoyo called yo')
app, cli, _ = get_app()
t.ar.to_app(app) # type: ignore
test_eq(cli.get('/hi').text, 'hi there')
test_eq(cli.get('/yoyo').text, 'a yoyo called yo')
test_eq(cli.get('/foo').text, 'foo yo')
test_eq(t.ar.yoyo.to(), '/yoyo') # type: ignoreIf you don’t need to initialize APIRouterD (e.g. prefix), or setup it afterwards, or mind defining the APIRouterD attribute explicitly, use APIRoute or the ar shortcut. It works fine if you don´t mind type checking false negatives.
If like me, you like static type checking at dev time but not stupid wiggly reds ~~~, inherit from RouteProvider.
class A(RouteProvider):
_value:Any
@ar
def value(self): return self._value
class B(A):
@ar.post
def value(self, value:int|None=None):
if value is not None: self._value = value
return self._value
@dataclass
class C(B):
_value:int = 3
app, cli, _ = get_app()
c = C()
c.ar.to_app(app)
test_eq(cli.get('/value').text, '3')
test_eq(cli.post('/value?value=7').text, '7')
test_eq(c.value(), 7)Adding routes to the app
Convenience functions for registering routes from providers into FastHTML apps.
FastHTML, Notebooks, Scope & State
Current
APIRouterincarnation has a complicated relationship with scope and state.
def noelmeter(n): return f"Ho {('ho '*n).strip()}!"
ar = APIRouter()
state = {'count': 1}
@ar("/ho")
def ho(req):
return f"{noelmeter(state['count'])} {ar.ho.to()} {req.url_for('ho')}"Note that ho access globals state and ar.
app,cli,_ = get_app()
ar.to_app(app)
print(app.routes)[Route(path='/ho', name='ho', methods=['GET', 'HEAD', 'POST'])]
test_eq(cli.get('/ho').text, 'Ho ho! /ho http://nb/ho')
test_eq(app.url_path_for('ho'), '/ho')Now let´s create another APIRouter with a separate state.
ar2 = APIRouter("/products")
state2 = {'count': 3}
@ar2("/ho")
def ho(req):
return f"{noelmeter(state2['count'])} {ar2.ho.to()} {req.url_for('ho')}"
ar2.to_app(app)
print(app.routes)[Route(path='/ho', name='ho', methods=['GET', 'HEAD', 'POST']), Route(path='/products/ho', name='ho', methods=['GET', 'HEAD', 'POST'])]
We must use prefix to preserve ar routes when adding the new ones.
test_eq(cli.get('/ho').text, 'Ho ho! /ho http://nb/ho')
test_eq(cli.get('/products/ho').text, 'Ho ho ho ho! /products/ho http://nb/ho') # <-- wrong
test_eq(app.url_path_for('ho'), '/ho')Routes @ /ho and /products/ho got the same name so either app or req can’t use reverse URL lookup to get the correct path of ar2.ho.
We may need to add ar2 to another app because both ar and ar2 routes share names, and then install app2 as a submount.
app.routes.clear()
ar.to_app(app)
ar2 = APIRouter() # we can`t use prefix here
@ar2("/ho")
def ho(req):
return f"{noelmeter(state2['count'])} {ar2.ho.to()} {req.url_for('ho')}"
app2,cli2,_ = get_app()
ar2.to_app(app2)
app.routes.append(Mount('/products', app2))
print(app.routes)
print(app.routes[-1].app.routes) # type: ignore[Route(path='/ho', name='ho', methods=['GET', 'HEAD', 'POST']), Mount(path='/products', name='', app=<fasthtml.core.FastHTML object at 0x1482269c0>)]
[Route(path='/ho', name='ho', methods=['GET', 'HEAD', 'POST'])]
test_eq(cli.get('/ho').text, 'Ho ho! /ho http://nb/ho')
test_eq(cli.get('/products/ho').text, 'Ho ho ho ho! /ho http://nb/ho') # <-- wrong
test_eq(app.url_path_for('ho'), '/ho')Nope. We can´t rely on req.url_for(...) or app.url_path_fpr(...) unless we add another global dependency.
app.routes.clear()
ar.to_app(app)
ar2 = APIRouter()
@ar2("/ho")
def ho(req):
return f"{noelmeter(state2['count'])} /products{ar2.ho.to()} {req.url_for('products:ho')}" # or
# f"{noelmeter(state2['count'])} {ar2.prefix}{ar2.ho.to()} {req.url_for('products:ho')}"
app2,cli2,_ = get_app()
ar2.to_app(app2)
app.routes.append(Mount('/products', app2, name='products')) # <-- assume `ar2` mounted with the name `products`test_eq(cli.get('/ho').text, 'Ho ho! /ho http://nb/ho')
test_eq(cli.get('/products/ho').text, 'Ho ho ho ho! /products/ho http://nb/products/ho')
test_eq(app.url_path_for('ho'), '/ho')
test_eq(app.url_path_for('products:ho'), '/products/ho')The fix above finally works. But now not only ho depends on globals ar2 and state2, also on knowledge of the mount route name.
add_routes
Besides support for methods, APIRouterC also has features to ease adding/mounting APIRouters.
mount
mount (app:fasthtml.core.FastHTML, prov:fasthtml.core.APIRouter|__main__.RouteProviderP, path:str|None=None, name:str|None=None)
add_routes
add_routes (prov:fasthtml.core.APIRouter|__main__.RouteProviderP|typing. Any, mount:bool=False, path:str|None=None, name:str|None=None, appcls:Callable=<function nb_app>)
Register provider routes
| Type | Default | Details | |
|---|---|---|---|
| prov | fasthtml.core.APIRouter | main.RouteProviderP | typing.Any | APIRouterC or RouteProvider | |
| mount | bool | False | mount routes under path; if false will add routes under APIRouter prefix |
| path | str | None | None | if mount, submount path (or auto-generated based on prov class) |
| name | str | None | None | if mount, name for submount (or auto-generated based on path) |
| appcls | Callable | nb_app | use FastHTML factory for submounts |
| Returns | APIRouter |
add_routes handles two scenarios: 1. mount is False. This is equivalent to APIRouter.to_app, the routes are available under APIRuter.prefix. 2. mount is True. The routes are mounted under path and are available under path/prefix.
App routes can be organized at two levels: 1. root Level: Global routes 2. Provider Level: Scoped routes defined by providers
This allows for clean organization of routes and natural encapsulation of component behavior directly in the notebook. This will be handy when we start defining ipywidgets like widgets with FastHTML.
Though I think it’s convenient, you don’t need to mount routes providers at all, add its routes directly to the root level app.
ar = APIRouteclass Counter(RouteProvider):
def __init__(self, value:int=0): self._value = value
@ar('/value', name='value')
def get_value(self):
return self._value # `0` is not a valid FastHTML response
@ar('/inc')
def increment(self, x:int=1):
self._value += x
return self.get_value()
app, cli, _ = get_app()
counter = Counter()
add_routes(app, counter)
print(app.routes)
test_eq(cli.get('/value').text, '')
test_eq(cli.get('/inc').text, '1')[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD'])]
Add routes to apps (notebook-level app above). Routes available under {self.ar.prefix}/... or {self.ar.to()}/...
add_routes returns the APIRouter used to define the routes.
counter2 = Counter(7)
add_routes(app, counter2)
print(app.routes)
test_eq(cli.get('/value').text, '7')
test_eq(cli.get(f"{counter2.ar.to()}/value").text, '7')[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD'])]
Note, however, routes are bound to a given Counter instance. Existing routes will be overwritten if you add routes from another instance.
counter3 = Counter(17)
# car3 = add_routes(app, counter3, path='/counter', name='cnt') # routes under /... and /counter/...
car3 = add_routes(app, counter3, path='/counter') # routes under /... and /counter/...
print(app.routes)
print(app.routes[-1].app.routes) # type: ignore
test_is(counter3.ar, car3)
test_eq(cli.get('/inc').text, '8')
test_eq(cli.get(car3.get_value.to()).text, '17')
test_eq(cli.get('/counter/inc').text, '18')
test_eq(app.url_path_for('value'), '/value')
test_eq(app.url_path_for(car3.name('value')), '/counter/value')[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD']), Mount(path='/counter', name='counter', app=<fasthtml.core.FastHTML object at 0x148226570>)]
[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD'])]
You can add instance routes under different path prefix.
Note add_routes with path argument will auto generate a name for the route if not provided.
counter32 = Counter(177)
car32= add_routes(app, counter32, path='/counter', name='cnt') # routes under /... and /counter/...
print(app.routes)
print(app.routes[-1].app.routes) # type: ignore
test_eq(cli.get(car32.get_value.to()).text, '177')
test_eq(cli.get('/counter/inc').text, '178')
test_eq(app.url_path_for('value'), '/value')
test_eq(app.url_path_for(car32.name('value')), '/counter/value')[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD']), Mount(path='/counter', name='cnt', app=<fasthtml.core.FastHTML object at 0x148226840>)]
[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD'])]
Use mount to submount APIRouters under class name (or add_routes(..., mount=True, ...)).
counter4 = Counter(23)
car4 = mount(app, counter4, '/counter', 'counter') # routes under /..., /counter/..., and /Counter/counter/...
print(app.routes)
print(app.routes[-1].app.routes) # type: ignore
print(app.routes[-1].app.routes[-1].routes) # type: ignore
test_eq(cli.get('/inc').text, '9')
test_eq(cli.get('/counter/inc').text, '179')
test_eq(cli.get('/Counter/counter/value').text, '23')
test_eq(cli.get('/Counter/counter/inc').text, '24')
test_eq(cli.get(car4.get_value.to()).text, '24')
test_eq(cli.get(f"{car4.to()}/value").text, '24')
test_eq(car4.name('increment'), 'Counter:counter:increment')[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD']), Mount(path='/counter', name='cnt', app=<fasthtml.core.FastHTML object at 0x148226840>), Mount(path='/Counter', name='Counter', app=<fasthtml.core.FastHTML object at 0x14823f440>)]
[Mount(path='/counter', name='counter', app=<fasthtml.core.FastHTML object at 0x14823e510>)]
[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD'])]
Mount with explicit path.
An APIRouterC from an instance, discloses its mounted name with self.ar.name().
counter5 = Counter(-7)
car5 = mount(app, counter5) # routes under /counter/..., and root.
print(app.routes)
print(app.routes[-1].app.routes) # type: ignore
print(app.routes[-1].app.routes[-1].routes) # type: ignore
test_eq(cli.get(f"{car5.to()}/value").text, '-7')
test_eq(cli.get(f"{car5.increment.to()}").text, '-6')
test_eq(cli.get(car5.to('increment')).text, '-5')[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD']), Mount(path='/counter', name='cnt', app=<fasthtml.core.FastHTML object at 0x148226840>), Mount(path='/Counter', name='Counter', app=<fasthtml.core.FastHTML object at 0x14823f440>)]
[Mount(path='/counter', name='counter', app=<fasthtml.core.FastHTML object at 0x14823e510>), Mount(path='/Counter_1-1764770681', name='Counter_1-1764770681', app=<fasthtml.core.FastHTML object at 0x1482609b0>)]
[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD'])]
Mount routes with automatic path generation.
counter6 = Counter(111)
car6 = mount(app, counter6, path='/')
print(app.routes)
print(app.routes[-1].app.routes) # type: ignore
test_eq(cli.get(f"{car6.prefix}/value").text, '111')
test_eq(cli.get(car6.to('increment')).text, '112')[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD']), Mount(path='/counter', name='cnt', app=<fasthtml.core.FastHTML object at 0x148226840>), Mount(path='/Counter', name='Counter', app=<fasthtml.core.FastHTML object at 0x14823f440>)]
[Mount(path='/counter', name='counter', app=<fasthtml.core.FastHTML object at 0x14823e510>), Mount(path='/Counter_1-1764770681', name='Counter_1-1764770681', app=<fasthtml.core.FastHTML object at 0x1482609b0>), Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='increment', methods=['GET', 'HEAD'])]
counter_6 routes added at Counter submount as root routes.
Thus add_routes can be used to add routes to an app with instance-based routes: - mount=False, path=None => root-level routes (e.g. /inc). Equivalent to FastHTML APIRouter.to_app - mount=False, path=…: root-level subroutes (e.g. /counter/inc). Equivalent to FastHTML APIRouter.to_app + prefix - mount=True, path=None => auto routes scoped under class name (e.g. /Counter/inc ) - mount=True, path=… => auto routes scoped under class name and path (e.g. /Counter/counter/inc )
Route introspection
class Counter(RouteProvider):
def __init__(self): self._value = 0
@ar('/value', name='value')
def get_value(self) -> int:
return self._value # `0` (int(0)) is not a valid FastHTML response (will be converted to `''`)
@ar('/inc', name='inc')
def increment(self):
self._value += 1
return self.get_value()
@ar('/add/{x}')
def add(self, x:int):
self._value += x
return self.get_value()
@ar.post('/sub')
def sub(self, x:int):
self._value -= x
return self.get_value()
@ar
def link(self, req):
nm = self.ar.name('add')
req.url_for(f"{nm}", x=2)
return (Div('+ 3', link=uri(nm, x='3')),
Div('+ 2', href=f"{req.url_for(f"{nm}", x=2)}"))
app, cli, rt = get_app()
c = Counter()
test_eq(c.increment(), 1)
c.ar.to_app(app)
test_eq(c.ar.name(), '')
test_eq(cli.get('/inc').text, '2')
test_eq(cli.get('/add/5').text, '7')
test_eq(cli.post('/sub', data={'x':2}).text, '5') # type: ignore
test_eq(cli.post('/sub?x=3').text, '2')
test_eq(cli.get('/link').text, ' <div href="/add/3">+ 3</div>\n <div href="http://nb/add/2">+ 2</div>\n')
test_eq(c.increment(), 3)test_eq(c.ar.increment.to(), '/inc')
test_eq(c.ar.inc.to(), '/inc')
test_eq(c.ar.add.to(), '/add/{x}')
test_eq(c.ar.to('add'), '/add/{x}')
test_eq(c.ar.to('sub', x=3), '/sub?x=3')
test_eq(app.url_path_for(c.ar.name('inc')), '/inc')
test_eq(app.url_path_for(c.ar.name('add'), x=5), '/add/5')
test_eq(app.url_path_for(c.ar.name('sub')), '/sub')app, cli, rt = get_app()
c = Counter()
add_routes(app, c, path='/counter')
# mount(app, c, '/counter')
print(app.routes)
print(app.routes[-1].app.routes) # type: ignore
test_eq(c.ar.prefix, '/counter')
test_eq(c.ar.add.to(), '/counter/add/{x}')
test_eq(c.ar.to('increment'), '/counter/inc')
test_eq(c.ar.to('inc'), '/counter/inc')
test_eq(c.ar.to('add'), '/counter/add/{x}')
test_eq(c.ar.to('sub', x=3), '/counter/sub?x=3')
test_eq(c.ar.name('inc'), f"counter:inc")
test_eq(app.url_path_for(c.ar.name('inc')), '/counter/inc')
test_eq(app.url_path_for(c.ar.name('link')), '/counter/link')
test_eq(app.url_path_for(c.ar.name('add'), x=5), '/counter/add/5')[Mount(path='/counter', name='counter', app=<fasthtml.core.FastHTML object at 0x148213260>)]
[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='inc', methods=['GET', 'HEAD']), Route(path='/add/{x}', name='add', methods=['GET', 'HEAD']), Route(path='/sub', name='sub', methods=['POST']), Route(path='/link', name='link', methods=['GET', 'HEAD'])]
Above is equivalent to_app using a custom prefix. Mounting another instance at same path will override the routes.
app, cli, rt = get_app()
c = Counter()
mount(app, c, '/counter')
print(app.routes)
print(app.routes[-1].app.routes) # type: ignore
print(app.routes[-1].app.routes[-1].app.routes) # type: ignore
test_eq(c.ar.prefix, '/Counter/counter')
test_eq(c.ar.add.to(), '/Counter/counter/add/{x}')
test_eq(c.ar.to('increment'), '/Counter/counter/inc')
test_eq(c.ar.to('add'), '/Counter/counter/add/{x}')
test_eq(c.ar.to('sub', x=3), '/Counter/counter/sub?x=3')
test_eq(app.url_path_for(c.ar.name('link')), '/Counter/counter/link')
test_eq(c.ar.name('increment'), f"Counter:counter:inc")
test_eq(app.url_path_for(c.ar.name('inc')), '/Counter/counter/inc')
test_eq(app.url_path_for(c.ar.name('add'), x=5), '/Counter/counter/add/5')[Mount(path='/Counter', name='Counter', app=<fasthtml.core.FastHTML object at 0x1482245c0>)]
[Mount(path='/counter', name='counter', app=<fasthtml.core.FastHTML object at 0x148279730>)]
[Route(path='/value', name='value', methods=['GET', 'HEAD']), Route(path='/inc', name='inc', methods=['GET', 'HEAD']), Route(path='/add/{x}', name='add', methods=['GET', 'HEAD']), Route(path='/sub', name='sub', methods=['POST']), Route(path='/link', name='link', methods=['GET', 'HEAD'])]
app, cli, rt = get_app()
c = Counter()
mount(app, c, '/counter', 'cnt')
test_eq(app.url_path_for(c.ar.name('link')), '/Counter/counter/link')
test_eq(c.ar.name('increment'), f"Counter:cnt:inc")
test_eq(app.url_path_for(c.ar.name('inc')), '/Counter/counter/inc')
test_eq(app.url_path_for(c.ar.name('add'), x=5), '/Counter/counter/add/5')Examples
bridge_cfg.auto_show = True
show(DetailsJSON(bridge_cfg.as_dict()))app = FastHTML()
rt = app.route
server = JupyUviB(app)render_ft()fh_cfg['auto_id']=True
show(Script(src='https://unpkg.com/htmx.org@2.0.4/dist/htmx.js'), fhjsscr, scopesrc, surrsrc)
clear_output()display(Javascript('''
if (window.htmx) htmx.process(document.body);
'''))
clear_output()Below we define several hypothetical product related routes in a Products and then demonstrate how they can seamlessly be incorporated into a FastHTML app instance.
class Products:
@ar('/all')
def all_products(self, req):
return Div(
"Welcome to the Products Page! Click the button below to look at the details for product 42",
Div(
Button(
'Details',
hx_get=self.ar.to('details', pid=42), # type: ignore
hx_target='#products_list',
hx_swap='outerHTML',
),
),
id='products_list',
)
@ar('/{pid}', name='details') # or @ar('/{pid}') or @ar.get('/{pid}') or @ar.get('/{pid}', name='details')
def details(self, pid: int):
return f"Here are the product details for ID: {pid}"
products = Products()
add_routes(app, products, path='/products', name='products')
products.ar.all_products.to(), str(products.ar.rt_funcs.details) # type: ignore('/products/all', '/products/{pid}')
Since we specified the path='/products' when mounting our hypothetical object, all routes defined in that provider will be found under /products.
Div(
"Click me for a look at our products",
hx_get=products.ar.all_products, # type: ignore
hx_swap="outerHTML",
)Note how you can reference our python route functions via APIRouter.rt_funcs or APIRouter.{name} or APIRouter.to('name') in your hx_{http_method} calls like normal.
server.stop()