Skip to content

Commit a1671de

Browse files
committed
improved APIRouter to allow for prefixs and discrovery
1 parent acbbeb6 commit a1671de

File tree

3 files changed

+189
-19
lines changed

3 files changed

+189
-19
lines changed

fasthtml/_modidx.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
'fasthtml.core': { 'fasthtml.core.APIRouter': ('api/core.html#apirouter', 'fasthtml/core.py'),
2727
'fasthtml.core.APIRouter.__call__': ('api/core.html#apirouter.__call__', 'fasthtml/core.py'),
2828
'fasthtml.core.APIRouter.__init__': ('api/core.html#apirouter.__init__', 'fasthtml/core.py'),
29+
'fasthtml.core.APIRouter._wrap_func': ('api/core.html#apirouter._wrap_func', 'fasthtml/core.py'),
2930
'fasthtml.core.APIRouter.to_app': ('api/core.html#apirouter.to_app', 'fasthtml/core.py'),
3031
'fasthtml.core.APIRouter.ws': ('api/core.html#apirouter.ws', 'fasthtml/core.py'),
3132
'fasthtml.core.Beforeware': ('api/core.html#beforeware', 'fasthtml/core.py'),
@@ -58,6 +59,11 @@
5859
'fasthtml.core.Redirect': ('api/core.html#redirect', 'fasthtml/core.py'),
5960
'fasthtml.core.Redirect.__init__': ('api/core.html#redirect.__init__', 'fasthtml/core.py'),
6061
'fasthtml.core.Redirect.__response__': ('api/core.html#redirect.__response__', 'fasthtml/core.py'),
62+
'fasthtml.core.RouteFuncs': ('api/core.html#routefuncs', 'fasthtml/core.py'),
63+
'fasthtml.core.RouteFuncs.__dir__': ('api/core.html#routefuncs.__dir__', 'fasthtml/core.py'),
64+
'fasthtml.core.RouteFuncs.__getattr__': ('api/core.html#routefuncs.__getattr__', 'fasthtml/core.py'),
65+
'fasthtml.core.RouteFuncs.__init__': ('api/core.html#routefuncs.__init__', 'fasthtml/core.py'),
66+
'fasthtml.core.RouteFuncs.__setattr__': ('api/core.html#routefuncs.__setattr__', 'fasthtml/core.py'),
6167
'fasthtml.core.StringConvertor.to_string': ('api/core.html#stringconvertor.to_string', 'fasthtml/core.py'),
6268
'fasthtml.core._add_ids': ('api/core.html#_add_ids', 'fasthtml/core.py'),
6369
'fasthtml.core._annotations': ('api/core.html#_annotations', 'fasthtml/core.py'),

fasthtml/core.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'parsed_date', 'snake2hyphens', 'HtmxHeaders',
88
'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'flat_xt', 'Beforeware', 'EventStream',
99
'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', 'noop_body', 'respond', 'Redirect', 'get_key', 'qp',
10-
'def_hdrs', 'FastHTML', 'serve', 'Client', 'APIRouter', 'cookie', 'reg_re_param', 'MiddlewareBase',
11-
'FtResponse', 'unqid', 'setup_ws']
10+
'def_hdrs', 'FastHTML', 'serve', 'Client', 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param',
11+
'MiddlewareBase', 'FtResponse', 'unqid', 'setup_ws']
1212

1313
# %% ../nbs/api/00_core.ipynb
1414
import json,uuid,inspect,types,uvicorn,signal,asyncio,threading,inspect
@@ -653,22 +653,53 @@ async def _request(): return await self.cli.request(method, url, **kwargs)
653653

654654
for o in ('get', 'post', 'delete', 'put', 'patch', 'options'): setattr(Client, o, partialmethod(Client._sync, o))
655655

656+
# %% ../nbs/api/00_core.ipynb
657+
class RouteFuncs:
658+
def __init__(self): super().__setattr__('_funcs', {})
659+
def __setattr__(self, name, value): self._funcs[name] = value
660+
def __getattr__(self, name):
661+
if name in all_meths: raise KeyError("Route functions with HTTP Names are not accessible here")
662+
return self._funcs[name]
663+
def __dir__(self): return list(self._funcs.keys())
664+
656665
# %% ../nbs/api/00_core.ipynb
657666
class APIRouter:
658667
"Add routes to an app"
659-
def __init__(self): self.routes,self.wss = [],[]
668+
def __init__(self, prefix:str|None=None):
669+
self.routes,self.wss = [],[]
670+
self.rt_funcs = RouteFuncs() # Store wrapped functions for discoverability
671+
self.prefix = prefix if prefix else ""
660672

661-
def __call__(self:FastHTML, path:str=None, methods=None, name=None, include_in_schema=True, body_wrap=noop_body):
673+
def _wrap_func(self, func, path=None):
674+
name = func.__name__
675+
676+
class _lf:
677+
def __init__(s): update_wrapper(s, func)
678+
def __call__(s, *args, **kw): return func(*args, **kw)
679+
def to(s, **kw): return qp(path, **kw)
680+
def __str__(s): return path
681+
682+
wrapped = _lf()
683+
wrapped.__routename__ = name
684+
# If you are using the def get or def post method names, this approach is not supported
685+
if name not in all_meths: setattr(self.rt_funcs, name, wrapped)
686+
return wrapped
687+
688+
def __call__(self, path:str=None, methods=None, name=None, include_in_schema=True, body_wrap=noop_body):
662689
"Add a route at `path`"
663-
def f(func): return self.routes.append((func, path,methods,name,include_in_schema,body_wrap))
690+
def f(func):
691+
p = self.prefix + ("/" + ('' if path.__name__=='index' else func.__name__) if callable(path) else path)
692+
wrapped = self._wrap_func(func, p)
693+
self.routes.append((func, p, methods, name, include_in_schema, body_wrap))
694+
return wrapped
664695
return f(path) if callable(path) else f
665696

666697
def to_app(self, app):
667698
"Add routes to `app`"
668699
for args in self.routes: app._add_route(*args)
669-
for args in self.wss : app._add_ws (*args)
670-
671-
def ws(self:FastHTML, path:str, conn=None, disconn=None, name=None, middleware=None):
700+
for args in self.wss: app._add_ws(*args)
701+
702+
def ws(self, path:str, conn=None, disconn=None, name=None, middleware=None):
672703
"Add a websocket route at `path`"
673704
def f(func=noop): return self.wss.append((func, path, conn, disconn, name, middleware))
674705
return f

nbs/api/00_core.ipynb

Lines changed: 144 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@
131131
{
132132
"data": {
133133
"text/plain": [
134-
"datetime.datetime(2024, 11, 4, 14, 0)"
134+
"datetime.datetime(2024, 12, 3, 14, 0)"
135135
]
136136
},
137137
"execution_count": null,
@@ -1229,7 +1229,7 @@
12291229
{
12301230
"data": {
12311231
"text/plain": [
1232-
"'77486da1-c613-48be-80c4-9cae89eeec48'"
1232+
"'7eb16e0e-f1b9-4266-98fa-2d84c34f86fc'"
12331233
]
12341234
},
12351235
"execution_count": null,
@@ -2423,13 +2423,13 @@
24232423
"name": "stdout",
24242424
"output_type": "stream",
24252425
"text": [
2426-
"Set to 2024-11-04 15:30:23.038930\n"
2426+
"Set to 2024-12-03 13:51:41.200169\n"
24272427
]
24282428
},
24292429
{
24302430
"data": {
24312431
"text/plain": [
2432-
"'Session time: 2024-11-04 15:30:23.038930'"
2432+
"'Session time: 2024-12-03 13:51:41.200169'"
24332433
]
24342434
},
24352435
"execution_count": null,
@@ -2608,6 +2608,23 @@
26082608
"## APIRouter"
26092609
]
26102610
},
2611+
{
2612+
"cell_type": "code",
2613+
"execution_count": null,
2614+
"id": "d5223a9a",
2615+
"metadata": {},
2616+
"outputs": [],
2617+
"source": [
2618+
"#| export\n",
2619+
"class RouteFuncs:\n",
2620+
" def __init__(self): super().__setattr__('_funcs', {})\n",
2621+
" def __setattr__(self, name, value): self._funcs[name] = value\n",
2622+
" def __getattr__(self, name): \n",
2623+
" if name in all_meths: raise KeyError(\"Route functions with HTTP Names are not accessible here\")\n",
2624+
" return self._funcs[name]\n",
2625+
" def __dir__(self): return list(self._funcs.keys())"
2626+
]
2627+
},
26112628
{
26122629
"cell_type": "code",
26132630
"execution_count": null,
@@ -2618,19 +2635,41 @@
26182635
"#| export\n",
26192636
"class APIRouter:\n",
26202637
" \"Add routes to an app\"\n",
2621-
" def __init__(self): self.routes,self.wss = [],[]\n",
2638+
" def __init__(self, prefix:str|None=None): \n",
2639+
" self.routes,self.wss = [],[]\n",
2640+
" self.rt_funcs = RouteFuncs() # Store wrapped functions for discoverability\n",
2641+
" self.prefix = prefix if prefix else \"\"\n",
26222642
"\n",
2623-
" def __call__(self:FastHTML, path:str=None, methods=None, name=None, include_in_schema=True, body_wrap=noop_body):\n",
2643+
" def _wrap_func(self, func, path=None):\n",
2644+
" name = func.__name__\n",
2645+
" \n",
2646+
" class _lf:\n",
2647+
" def __init__(s): update_wrapper(s, func)\n",
2648+
" def __call__(s, *args, **kw): return func(*args, **kw)\n",
2649+
" def to(s, **kw): return qp(path, **kw)\n",
2650+
" def __str__(s): return path\n",
2651+
" \n",
2652+
" wrapped = _lf()\n",
2653+
" wrapped.__routename__ = name\n",
2654+
" # If you are using the def get or def post method names, this approach is not supported\n",
2655+
" if name not in all_meths: setattr(self.rt_funcs, name, wrapped)\n",
2656+
" return wrapped\n",
2657+
"\n",
2658+
" def __call__(self, path:str=None, methods=None, name=None, include_in_schema=True, body_wrap=noop_body):\n",
26242659
" \"Add a route at `path`\"\n",
2625-
" def f(func): return self.routes.append((func, path,methods,name,include_in_schema,body_wrap))\n",
2660+
" def f(func):\n",
2661+
" p = self.prefix + (\"/\" + ('' if path.__name__=='index' else func.__name__) if callable(path) else path)\n",
2662+
" wrapped = self._wrap_func(func, p)\n",
2663+
" self.routes.append((func, p, methods, name, include_in_schema, body_wrap))\n",
2664+
" return wrapped\n",
26262665
" return f(path) if callable(path) else f\n",
26272666
"\n",
26282667
" def to_app(self, app):\n",
26292668
" \"Add routes to `app`\"\n",
26302669
" for args in self.routes: app._add_route(*args)\n",
2631-
" for args in self.wss : app._add_ws (*args)\n",
2632-
"\n",
2633-
" def ws(self:FastHTML, path:str, conn=None, disconn=None, name=None, middleware=None):\n",
2670+
" for args in self.wss: app._add_ws(*args)\n",
2671+
" \n",
2672+
" def ws(self, path:str, conn=None, disconn=None, name=None, middleware=None):\n",
26342673
" \"Add a websocket route at `path`\"\n",
26352674
" def f(func=noop): return self.wss.append((func, path, conn, disconn, name, middleware))\n",
26362675
" return f"
@@ -2663,11 +2702,25 @@
26632702
"def show_host(req): return req.headers['host']\n",
26642703
"@ar\n",
26652704
"def yoyo(): return 'a yoyo'\n",
2705+
"@ar\n",
2706+
"def index(): return \"home page\"\n",
26662707
"\n",
26672708
"@ar.ws(\"/ws\")\n",
26682709
"def ws(self, msg:str): return f\"Message text was: {msg}\""
26692710
]
26702711
},
2712+
{
2713+
"cell_type": "code",
2714+
"execution_count": null,
2715+
"id": "8c265ff8",
2716+
"metadata": {},
2717+
"outputs": [],
2718+
"source": [
2719+
"assert str(ar.rt_funcs.index) == '/'\n",
2720+
"assert str(yoyo) == '/yoyo'\n",
2721+
"assert \"get\" not in ar.rt_funcs._funcs.keys()"
2722+
]
2723+
},
26712724
{
26722725
"cell_type": "code",
26732726
"execution_count": null,
@@ -2708,6 +2761,79 @@
27082761
" assert data == 'Message text was: Hi!'"
27092762
]
27102763
},
2764+
{
2765+
"cell_type": "code",
2766+
"execution_count": null,
2767+
"id": "02a4e649",
2768+
"metadata": {},
2769+
"outputs": [],
2770+
"source": [
2771+
"ar2 = APIRouter(\"/products\")"
2772+
]
2773+
},
2774+
{
2775+
"cell_type": "code",
2776+
"execution_count": null,
2777+
"id": "151b9e3c",
2778+
"metadata": {},
2779+
"outputs": [],
2780+
"source": [
2781+
"@ar2(\"/hi\")\n",
2782+
"def get(): return 'Hi there'\n",
2783+
"@ar2(\"/hi\")\n",
2784+
"def post(): return 'Postal'\n",
2785+
"@ar2\n",
2786+
"def ho(): return 'Ho ho'\n",
2787+
"@ar2(\"/hostie\")\n",
2788+
"def show_host(req): return req.headers['host']\n",
2789+
"@ar2\n",
2790+
"def yoyo(): return 'a yoyo'\n",
2791+
"@ar2\n",
2792+
"def index(): return \"home page\"\n",
2793+
"\n",
2794+
"@ar2.ws(\"/ws\")\n",
2795+
"def ws(self, msg:str): return f\"Message text was: {msg}\""
2796+
]
2797+
},
2798+
{
2799+
"cell_type": "code",
2800+
"execution_count": null,
2801+
"id": "77ce8548",
2802+
"metadata": {},
2803+
"outputs": [],
2804+
"source": [
2805+
"app,cli,_ = get_cli(FastHTML())\n",
2806+
"ar2.to_app(app)"
2807+
]
2808+
},
2809+
{
2810+
"cell_type": "code",
2811+
"execution_count": null,
2812+
"id": "f265860d",
2813+
"metadata": {},
2814+
"outputs": [],
2815+
"source": [
2816+
"test_eq(cli.get('/products/hi').text, 'Hi there')\n",
2817+
"test_eq(cli.post('/products/hi').text, 'Postal')\n",
2818+
"test_eq(cli.get('/products/hostie').text, 'testserver')\n",
2819+
"test_eq(cli.post('/products/yoyo').text, 'a yoyo')\n",
2820+
"\n",
2821+
"test_eq(cli.get('/products/ho').text, 'Ho ho')\n",
2822+
"test_eq(cli.post('/products/ho').text, 'Ho ho')"
2823+
]
2824+
},
2825+
{
2826+
"cell_type": "code",
2827+
"execution_count": null,
2828+
"id": "f1fc8425",
2829+
"metadata": {},
2830+
"outputs": [],
2831+
"source": [
2832+
"assert str(ar2.rt_funcs.index) == '/products/'\n",
2833+
"assert str(yoyo) == '/products/yoyo'\n",
2834+
"assert \"get\" not in ar2.rt_funcs._funcs.keys()"
2835+
]
2836+
},
27112837
{
27122838
"cell_type": "code",
27132839
"execution_count": null,
@@ -2731,6 +2857,13 @@
27312857
"@ar.get(\"/hi3\")\n",
27322858
"def _(): return 'Hi there'\n",
27332859
"@ar.post(\"/post2\")\n",
2860+
"def _(): return 'Postal'\n",
2861+
"\n",
2862+
"@ar2.get\n",
2863+
"def hi2(): return 'Hi there'\n",
2864+
"@ar2.get(\"/hi3\")\n",
2865+
"def _(): return 'Hi there'\n",
2866+
"@ar2.post(\"/post2\")\n",
27342867
"def _(): return 'Postal'"
27352868
]
27362869
},
@@ -2794,7 +2927,7 @@
27942927
{
27952928
"data": {
27962929
"text/plain": [
2797-
"'Cookie was set at time 15:30:23.174646'"
2930+
"'Cookie was set at time 13:51:41.310412'"
27982931
]
27992932
},
28002933
"execution_count": null,

0 commit comments

Comments
 (0)