1
+ """See the `FastBuildRouter` class."""
2
+
1
3
from __future__ import annotations
2
4
3
5
import dataclasses
@@ -63,29 +65,52 @@ class _FastAPIRouteMethods:
63
65
64
66
65
67
class FastBuildRouter (_FastAPIRouteMethods ):
66
- """An optimized, stripped-down, drop-in replacement for `fastapi.APIRouter`.
67
-
68
- An essential part of the way we organize our code is to have a tree of topic-based
69
- subdirectories that each define their own HTTP routes with a local
70
- `fastapi.APIRouter`, and then combine those into a single `fastapi.FastAPI` app.
71
-
72
- Unfortunately, the standard FastAPI way of doing this, with
73
- `APIRouter.include_router()` and `FastAPI.include_router()`, appears to have severe
74
- performance problems. Supposedly, the bad performance has to do with reduntantly
75
- constructing Pydantic objects at each level of nesting
76
- (https://github.com/pydantic/pydantic/issues/6768#issuecomment-1644532429).
77
- This severely impacts server startup time.
78
-
79
- This class, a reimplementation of the `fastapi.APIRouter` interface, fixes that.
80
- This gives something like a 1.6x speedup for `import robot_server.app`.
81
- Not all features of `fastapi.APIRouter` are supported,
82
- only the ones that we actually need.
68
+ """An optimized drop-in replacement for `fastapi.APIRouter`.
69
+
70
+ Use it like `fastapi.APIRouter`:
71
+
72
+ foo_router = FastBuildRouter()
73
+
74
+ @router.get("/foo/{id}")
75
+ def get_health(id: str) -> Response:
76
+ ...
77
+
78
+ bar_router = ...
79
+
80
+ root_router = FastBuildRouter()
81
+ root_router.include_router(foo_router)
82
+ root_router.include_router(bar_router)
83
+
84
+ app = fastapi.FastAPI()
85
+ root_router.install_on_app(app)
86
+
87
+ Rationale:
88
+
89
+ With FastAPI's standard `FastAPI` and `APIRouter` classes, the `.include_router()`
90
+ method has a lot of overhead, accounting for something like 30-40% of
91
+ robot-server's startup time, which is multiple minutes long at the time of writing.
92
+ (https://github.com/pydantic/pydantic/issues/6768#issuecomment-1644532429)
93
+
94
+ We could avoid the overhead by adding endpoints directly to the top-level FastAPI
95
+ app, "flat," instead of using `.include_router()`. But that would be bad for code
96
+ organization; we want to keep our tree of sub-routers. So this class reimplements
97
+ the important parts of `fastapi.APIRouter`, so we can keep our router tree, but
98
+ in a lighter-weight way.
99
+
100
+ When you call `@router.get()` or `router.include_router()` on this class, it appends
101
+ to a lightweight internal structure and completely avoids slow calls into FastAPI.
102
+ Later on, when you do `router.install_on_app()`, everything in the tree is added to
103
+ the FastAPI app.
83
104
"""
84
105
85
106
def __init__ (self ) -> None :
86
107
self ._routes : list [_Endpoint | _IncludedRouter ] = []
87
108
88
109
def __getattr__ (self , name : str ) -> object :
110
+ """Supply the optimized version of `@router.get()`, `@router.post()`, etc.
111
+
112
+ See the FastAPI docs for usage details.
113
+ """
89
114
if name in _FASTAPI_ROUTE_METHOD_NAMES :
90
115
return _EndpointCaptor (method_name = name , on_capture = self ._routes .append )
91
116
else :
@@ -96,15 +121,21 @@ def include_router(
96
121
router : FastBuildRouter | fastapi .APIRouter ,
97
122
** kwargs : typing_extensions .Unpack [_RouterIncludeKwargs ],
98
123
) -> None :
99
- """The optimized version of `fastapi.APIRouter.include_router()`.""" # noqa: D402
124
+ """The optimized version of `fastapi.APIRouter.include_router()`.
125
+
126
+ See the FastAPI docs for argument details.
127
+ """ # noqa: D402
100
128
self ._routes .append (_IncludedRouter (router = router , inclusion_kwargs = kwargs ))
101
129
102
130
def install_on_app (
103
131
self ,
104
132
app : fastapi .FastAPI ,
105
133
** kwargs : typing_extensions .Unpack [_RouterIncludeKwargs ],
106
134
) -> None :
107
- """The optimized version of `fastapi.FastAPI.include_router()`."""
135
+ """The optimized version of `fastapi.FastAPI.include_router()`.
136
+
137
+ See the FastAPI docs for argument details..
138
+ """
108
139
for route in self ._routes :
109
140
if isinstance (route , _IncludedRouter ):
110
141
router = route .router
@@ -125,13 +156,14 @@ def install_on_app(
125
156
126
157
127
158
class _RouterIncludeKwargs (typing .TypedDict ):
128
- """The keyword arguments of `fastapi.APIRouter. include_router()`.
159
+ """The keyword arguments of FastAPI's `. include_router()` method .
129
160
130
- (At least the ones that we care about , anyway.)
161
+ (At least the arguments that we actually use , anyway.)
131
162
"""
132
163
133
164
# Arguments with defaults should be annotated as `NotRequired`.
134
165
# For example, `foo: str | None = None` becomes `NotRequired[str | None]`.
166
+
135
167
tags : typing_extensions .NotRequired [list [str | enum .Enum ] | None ]
136
168
responses : typing_extensions .NotRequired [
137
169
dict [int | str , dict [str , typing .Any ]] | None
@@ -155,7 +187,8 @@ def _merge_kwargs(
155
187
For example, the top-level router, subrouters, and finally the endpoint function
156
188
can each specify their own `tags`. The different levels need to be merged
157
189
carefully and in argument-specific ways if we want to match FastAPI behavior.
158
- For example, `tags` should be the concatenation of all levels.
190
+ For example, the final `tags` value should be the concatenation of the values
191
+ from all levels.
159
192
"""
160
193
merge_result : _RouterIncludeKwargs = {}
161
194
remaining_from_parent = from_parent .copy ()
@@ -208,35 +241,38 @@ class _IncludedRouter:
208
241
inclusion_kwargs : _RouterIncludeKwargs
209
242
210
243
211
- DecoratedFunctionT = typing .TypeVar (
212
- "DecoratedFunctionT " , bound = typing .Callable [..., object ]
244
+ _DecoratedFunctionT = typing .TypeVar (
245
+ "_DecoratedFunctionT " , bound = typing .Callable [..., object ]
213
246
)
214
247
215
248
216
249
class _EndpointCaptor :
250
+ """A callable that pretends to be a FastAPI path operation decorator.
251
+
252
+ `method_name` is the FastAPI method to pretend to be, e.g. "get" or "post".
253
+
254
+ Supposing you have an `_EndpointCaptor` named `get`, when this whole enchilada
255
+ happens:
256
+
257
+ @get("/foo/{id}", description="blah blah")
258
+ def get_some_endpoint(id: str) -> Response:
259
+ ...
260
+
261
+ Then information about the whole enchilada is sent to the `on_capture` callback.
262
+ """
263
+
217
264
def __init__ (
218
265
self ,
219
266
method_name : str ,
220
267
on_capture : typing .Callable [[_Endpoint ], None ],
221
268
) -> None :
222
- """
223
- Params:
224
- method_name: The name of the method on the fastapi.FastAPI class that this
225
- should proxy, e.g. "get" or "post".
226
- on_capture: Called when we capture a call,
227
- i.e. when some router module does:
228
-
229
- @router.get("/foo")
230
- def get_foo() -> FooResponse:
231
- ...
232
- """
233
269
self ._method_name = method_name
234
270
self ._on_capture = on_capture
235
271
236
272
def __call__ (
237
273
self , * fastapi_decorator_args : object , ** fastapi_decorator_kwargs : object
238
- ) -> typing .Callable [[DecoratedFunctionT ], DecoratedFunctionT ]:
239
- def decorate (decorated_function : DecoratedFunctionT ) -> DecoratedFunctionT :
274
+ ) -> typing .Callable [[_DecoratedFunctionT ], _DecoratedFunctionT ]:
275
+ def decorate (decorated_function : _DecoratedFunctionT ) -> _DecoratedFunctionT :
240
276
self ._on_capture (
241
277
_Endpoint (
242
278
method_name = self ._method_name ,
@@ -252,6 +288,8 @@ def decorate(decorated_function: DecoratedFunctionT) -> DecoratedFunctionT:
252
288
253
289
@dataclasses .dataclass
254
290
class _Endpoint :
291
+ """Information about an endpoint that's been added to a router."""
292
+
255
293
method_name : str
256
294
"""The name of the method on the FastAPI class, e.g. "get"."""
257
295
0 commit comments