fastapi-deferred-init 0.3.3__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-deferred-init
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Faster FastAPI start-up time for Projects with many nested routers
5
5
  Keywords: fastapi,speed,router,startup,optimization
6
6
  Author-Email: Jan Vollmer <jan@vllmr.dev>
@@ -9,16 +9,16 @@ Classifier: Environment :: Plugins
9
9
  Classifier: Framework :: FastAPI
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3.10
13
12
  Classifier: Programming Language :: Python :: 3.11
14
13
  Classifier: Programming Language :: Python :: 3.12
15
14
  Classifier: Programming Language :: Python :: 3.13
16
15
  Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: 3.15
17
17
  Classifier: Typing :: Typed
18
18
  Project-URL: Source, https://github.com/jvllmr/fastapi-deferred-init
19
19
  Project-URL: Changelog, https://github.com/jvllmr/fastapi-deferred-init/releases
20
- Requires-Python: >=3.10
21
- Requires-Dist: fastapi>=0.141.1
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: fastapi>=0.162.0
22
22
  Description-Content-Type: text/markdown
23
23
 
24
24
  # fastapi-deferred-init
@@ -12,24 +12,24 @@ authors = [
12
12
  { name = "Jan Vollmer", email = "jan@vllmr.dev" },
13
13
  ]
14
14
  dependencies = [
15
- "fastapi>=0.141.1",
15
+ "fastapi>=0.162.0",
16
16
  ]
17
- requires-python = ">=3.10"
17
+ requires-python = ">=3.11"
18
18
  readme = "README.md"
19
19
  classifiers = [
20
20
  "Environment :: Plugins",
21
21
  "Framework :: FastAPI",
22
22
  "Intended Audience :: Developers",
23
23
  "License :: OSI Approved :: MIT License",
24
- "Programming Language :: Python :: 3.10",
25
24
  "Programming Language :: Python :: 3.11",
26
25
  "Programming Language :: Python :: 3.12",
27
26
  "Programming Language :: Python :: 3.13",
28
27
  "Programming Language :: Python :: 3.14",
28
+ "Programming Language :: Python :: 3.15",
29
29
  "Typing :: Typed",
30
30
  ]
31
31
  dynamic = []
32
- version = "0.3.3"
32
+ version = "0.3.4"
33
33
 
34
34
  [project.license]
35
35
  text = "MIT"
@@ -45,9 +45,6 @@ requires = [
45
45
  build-backend = "pdm.backend"
46
46
 
47
47
  [tool.pdm]
48
- ignore_package_warnings = [
49
- "pre-commit",
50
- ]
51
48
  plugins = [
52
49
  "sync-pre-commit-lock",
53
50
  ]
@@ -69,7 +66,7 @@ FULL_TEST = "true"
69
66
 
70
67
  [tool.pdm.scripts.post_install]
71
68
  composite = [
72
- "pre-commit install",
69
+ "prek install",
73
70
  "pdm install --plugins --frozen-lock",
74
71
  "pip install ./fastapi",
75
72
  ]
@@ -80,18 +77,33 @@ source = "scm"
80
77
  [tool.isort]
81
78
  profile = "black"
82
79
 
83
- [tool.pycln]
84
- all = true
80
+ [tool.ruff]
81
+ target-version = "py311"
82
+
83
+ [tool.ruff.lint]
84
+ extend-select = [
85
+ "F",
86
+ ]
87
+
88
+ [tool.ty.environment]
89
+ python-version = "3.11"
90
+ python = ".venv"
91
+
92
+ [tool.ty.src]
93
+ include = [
94
+ "src",
95
+ "tests",
96
+ ]
85
97
 
86
98
  [dependency-groups]
87
99
  dev = [
88
- "pytest>=9.1.1",
89
- "ruff>=0.15.20",
90
- "pre-commit>=4.3.0",
91
- "mypy>=2.1.0",
100
+ "pytest>=9.1.1,<9.2",
101
+ "ruff>=0.17.1",
92
102
  "tuna>=0.5.15",
93
- "pytest-benchmark>=5.2.3",
94
- "pip>=26.1.2",
95
- "inline-snapshot>=0.34.2",
96
- "httpx2>=2.9.1",
103
+ "pytest-benchmark>=5.3.0",
104
+ "pip>=26.2.1",
105
+ "inline-snapshot>=0.36.1",
106
+ "httpx2>=2.14.0",
107
+ "prek>=0.5.9",
108
+ "ty>=0.0.95",
97
109
  ]
@@ -1,4 +1,9 @@
1
- from .routing import DeferringAPIRoute, DeferringAPIRouter, _populate_api_route_state
1
+ from .routing import (
2
+ DeferringAPIRoute,
3
+ DeferringAPIRouter,
4
+ _populate_api_route_state,
5
+ apply_include_patch,
6
+ )
2
7
 
3
8
 
4
9
  def apply_patch() -> None:
@@ -8,6 +13,7 @@ def apply_patch() -> None:
8
13
  routing._populate_api_route_state = _populate_api_route_state # ty:ignore[invalid-assignment]
9
14
  routing.APIRoute = DeferringAPIRoute # ty:ignore[invalid-assignment]
10
15
  routing.APIRouter = DeferringAPIRouter # ty:ignore[invalid-assignment]
16
+ apply_include_patch(routing)
11
17
 
12
18
  import fastapi
13
19
 
@@ -26,6 +26,7 @@ from fastapi.types import IncEx
26
26
  from fastapi.utils import (
27
27
  create_model_field,
28
28
  generate_unique_id,
29
+ get_value_or_default,
29
30
  is_body_allowed_for_status_code,
30
31
  )
31
32
 
@@ -125,10 +126,16 @@ def _populate_api_route_state(
125
126
  status_code = int(status_code)
126
127
  route.status_code = status_code
127
128
 
129
+ # Store the raw response_model / stream_item_type parameters so the
130
+ # deferred cached_properties can access them per-instance (not via
131
+ # closure, which would be shared across all instances of the class).
132
+ route._raw_response_model = response_model # ty: ignore[unresolved-attribute]
133
+ route._raw_stream_item_type = stream_item_type # ty: ignore[unresolved-attribute]
134
+
128
135
  def _response_field(self):
129
136
  if self.response_model:
130
- assert is_body_allowed_for_status_code(status_code), (
131
- f"Status code {status_code} must not have a response body"
137
+ assert is_body_allowed_for_status_code(self.status_code), (
138
+ f"Status code {self.status_code} must not have a response body"
132
139
  )
133
140
  response_name = "Response_" + self.unique_id
134
141
  return create_model_field(
@@ -234,11 +241,10 @@ def _populate_api_route_state(
234
241
  _add_cache_attribute(route, "is_json_stream", _is_json_stream)
235
242
 
236
243
  def _resolved_response_model_stream_item(self):
237
- nonlocal response_model, stream_item_type
238
- resolved_model = response_model
239
- resolved_item_type = stream_item_type
240
- if isinstance(response_model, DefaultPlaceholder):
241
- return_annotation = get_typed_return_annotation(endpoint)
244
+ resolved_model = self._raw_response_model
245
+ resolved_item_type = self._raw_stream_item_type
246
+ if isinstance(resolved_model, DefaultPlaceholder):
247
+ return_annotation = get_typed_return_annotation(self.endpoint)
242
248
  if lenient_issubclass(return_annotation, Response):
243
249
  resolved_model = None
244
250
  else:
@@ -251,8 +257,8 @@ def _populate_api_route_state(
251
257
  # wrapper, not a data model, so it shouldn't feed
252
258
  # into validation or OpenAPI schema generation.
253
259
  if (
254
- isinstance(response_class, DefaultPlaceholder)
255
- or lenient_issubclass(response_class, EventSourceResponse)
260
+ isinstance(self.response_class, DefaultPlaceholder)
261
+ or lenient_issubclass(self.response_class, EventSourceResponse)
256
262
  ) and not lenient_issubclass(stream_item, ServerSentEvent):
257
263
  resolved_item_type = stream_item
258
264
  resolved_model = None
@@ -297,3 +303,93 @@ class DeferringAPIRouter(routing.APIRouter):
297
303
  if "route_class" not in kwargs:
298
304
  kwargs["route_class"] = DeferringAPIRoute
299
305
  super().__init__(*args, **kwargs)
306
+
307
+
308
+ def _from_api_route_deferred(
309
+ cls: type, *, original_route: Any, include_context: Any
310
+ ) -> Any:
311
+ """Deferring replacement for fastapi>=0.162's `_EffectiveRouteContext.from_api_route`.
312
+
313
+ Upstream eagerly builds the ASGI app for every route of an included router
314
+ (`context.app = request_response(original_route.get_route_handler())`),
315
+ which forces the deferred dependant/body_field/response_model to be
316
+ computed at include time. Defer `app` to first access instead, like
317
+ DeferringAPIRoute does for direct routes.
318
+ """
319
+ route = cast(routing._APIRouteLike, original_route)
320
+ context = cls(original_route=original_route)
321
+ _populate_api_route_state(
322
+ cast(routing._APIRouteLike, context),
323
+ include_context.path_for(original_route),
324
+ route.endpoint,
325
+ response_model=route.response_model,
326
+ status_code=route.status_code,
327
+ tags=[*include_context.tags, *route.tags],
328
+ dependencies=[*include_context.dependencies, *route.dependencies],
329
+ summary=route.summary,
330
+ description=route.description,
331
+ response_description=route.response_description,
332
+ responses={**include_context.responses, **route.responses},
333
+ deprecated=route.deprecated or include_context.deprecated,
334
+ methods=route.methods,
335
+ operation_id=route.operation_id,
336
+ response_model_include=route.response_model_include,
337
+ response_model_exclude=route.response_model_exclude,
338
+ response_model_by_alias=route.response_model_by_alias,
339
+ response_model_exclude_unset=route.response_model_exclude_unset,
340
+ response_model_exclude_defaults=route.response_model_exclude_defaults,
341
+ response_model_exclude_none=route.response_model_exclude_none,
342
+ include_in_schema=route.include_in_schema and include_context.include_in_schema,
343
+ response_class=get_value_or_default(
344
+ route.response_class,
345
+ include_context.included_router.default_response_class,
346
+ include_context.default_response_class,
347
+ ),
348
+ name=route.name,
349
+ dependency_overrides_provider=include_context.dependency_overrides_provider,
350
+ callbacks=[*include_context.callbacks, *(route.callbacks or [])],
351
+ openapi_extra=route.openapi_extra,
352
+ generate_unique_id_function=get_value_or_default(
353
+ route.generate_unique_id_function,
354
+ include_context.included_router.generate_unique_id_function,
355
+ include_context.generate_unique_id_function,
356
+ ),
357
+ strict_content_type=get_value_or_default(
358
+ route.strict_content_type,
359
+ include_context.included_router.strict_content_type,
360
+ include_context.strict_content_type,
361
+ ),
362
+ stream_item_type=route.stream_item_type,
363
+ )
364
+
365
+ def _app(self: Any) -> Any:
366
+ # Build the handler lazily with the effective context active, mirroring
367
+ # upstream's ContextVar usage so integrations wrapping dependant.call
368
+ # while constructing the handler still see the context.
369
+ context_var = getattr(routing, "_effective_route_context_var", None)
370
+ if context_var is not None:
371
+ token = context_var.set(self)
372
+ try:
373
+ handler = self.original_route.get_route_handler()
374
+ finally:
375
+ context_var.reset(token)
376
+ else:
377
+ handler = self.original_route.get_route_handler()
378
+ return routing.request_response(handler)
379
+
380
+ _add_cache_attribute(context, "app", _app)
381
+ return context
382
+
383
+
384
+ def apply_include_patch(routing_module: Any) -> bool:
385
+ """Patch fastapi>=0.162's include machinery to defer app construction.
386
+
387
+ Returns True when the patch was applied, i.e. the running fastapi version
388
+ has `_EffectiveRouteContext` (older versions re-run APIRoute.__init__ on
389
+ include, which the route-class patch already covers).
390
+ """
391
+ effective_route_context = getattr(routing_module, "_EffectiveRouteContext", None)
392
+ if effective_route_context is None:
393
+ return False
394
+ effective_route_context.from_api_route = classmethod(_from_api_route_deferred)
395
+ return True
@@ -1,10 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import importlib
4
+ import importlib.util
4
5
  import sys
5
6
  from contextlib import contextmanager
6
7
  from cProfile import Profile
7
8
  from pstats import SortKey, Stats
9
+ from typing import TYPE_CHECKING
10
+
11
+ from fastapi_deferred_init.routing import (
12
+ DeferringAPIRoute,
13
+ DeferringAPIRouter,
14
+ _from_api_route_deferred,
15
+ _populate_api_route_state,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ import pytest
8
20
 
9
21
 
10
22
  @contextmanager
@@ -26,6 +38,21 @@ def load_code():
26
38
  def import_via_file_path(module_name: str, file_path: str):
27
39
  # https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
28
40
  spec = importlib.util.spec_from_file_location(module_name, file_path)
41
+ assert spec is not None and spec.loader is not None
29
42
  module = importlib.util.module_from_spec(spec)
30
43
  sys.modules[module_name] = module
31
44
  spec.loader.exec_module(module)
45
+
46
+
47
+ def apply_monkeypatch(monkeypatch: pytest.MonkeyPatch) -> None:
48
+ monkeypatch.setattr(
49
+ "fastapi.routing._populate_api_route_state", _populate_api_route_state
50
+ )
51
+ monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
52
+ monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
53
+ monkeypatch.setattr("fastapi.APIRouter", DeferringAPIRouter)
54
+ monkeypatch.setattr(
55
+ "fastapi.routing._EffectiveRouteContext.from_api_route",
56
+ classmethod(_from_api_route_deferred),
57
+ raising=False,
58
+ )
@@ -8,11 +8,10 @@ from fastapi import APIRouter, FastAPI, routing
8
8
  from fastapi_deferred_init.routing import (
9
9
  DeferringAPIRoute,
10
10
  DeferringAPIRouter,
11
- _populate_api_route_state,
12
11
  )
13
12
 
14
13
  from .data.gen_code_ast import create_code
15
- from .helpers import import_via_file_path, load_code
14
+ from .helpers import apply_monkeypatch, import_via_file_path, load_code
16
15
 
17
16
 
18
17
  def skip_test(use_lib: bool):
@@ -20,15 +19,6 @@ def skip_test(use_lib: bool):
20
19
  pytest.skip()
21
20
 
22
21
 
23
- def apply_monkeypatch(monkeypatch):
24
- monkeypatch.setattr(
25
- "fastapi.routing._populate_api_route_state", _populate_api_route_state
26
- )
27
- monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
28
- monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
29
- monkeypatch.setattr("fastapi.APIRouter", DeferringAPIRouter)
30
-
31
-
32
22
  @pytest.mark.parametrize(["use_lib"], [(True,), (False,)])
33
23
  def test_basic(use_lib: bool, benchmark, monkeypatch):
34
24
  skip_test(use_lib=use_lib)
@@ -124,7 +114,7 @@ def test_fastapi_sse(monkeypatch):
124
114
  "fastapi/tests/test_sse.py",
125
115
  )
126
116
 
127
- from fastapi_clone.tests.test_sse import (
117
+ from fastapi_clone.tests.test_sse import ( # ty: ignore[unresolved-import]
128
118
  client_fixture as fastapi_client_fixture,
129
119
  )
130
120
  from fastapi_clone.tests.test_sse import ( # type: ignore
@@ -0,0 +1,314 @@
1
+ """Real-world usage tests for fastapi-deferred-init.
2
+
3
+ These tests mimic common patterns found in production FastAPI apps
4
+ that go beyond what the existing tests cover.
5
+ """
6
+
7
+ import typing as t
8
+
9
+ from fastapi.responses import JSONResponse, PlainTextResponse
10
+ from fastapi.testclient import TestClient
11
+ from pydantic import BaseModel
12
+
13
+ from fastapi import Depends, FastAPI, Header, HTTPException, Query, routing, status
14
+
15
+ from .helpers import apply_monkeypatch
16
+
17
+
18
+ class User(BaseModel):
19
+ id: int
20
+ name: str
21
+ email: str
22
+
23
+
24
+ class Item(BaseModel):
25
+ id: int
26
+ title: str
27
+ owner_id: int
28
+
29
+
30
+ def _make_app():
31
+ """Build a realistic app. Route creation order matters: router routes
32
+ are created before app-level routes because decorators run top-to-bottom."""
33
+
34
+ def get_current_user() -> str:
35
+ return "fake-token"
36
+
37
+ def get_db() -> str:
38
+ return "fake-db-conn"
39
+
40
+ # --- sub-router: items (created FIRST, so its closures get installed on the class) ---
41
+ items_router = routing.APIRouter(prefix="/items", tags=["items"])
42
+
43
+ @items_router.get("/{item_id}", response_model=Item)
44
+ def read_item(item_id: int, db: t.Annotated[str, Depends(get_db)]):
45
+ return Item(id=item_id, title=f"Item {item_id}", owner_id=1)
46
+
47
+ @items_router.post("", response_model=Item, status_code=status.HTTP_201_CREATED)
48
+ def create_item(item: Item, user: t.Annotated[str, Depends(get_current_user)]):
49
+ return item
50
+
51
+ # --- sub-router: users ---
52
+ users_router = routing.APIRouter(prefix="/users", tags=["users"])
53
+
54
+ @users_router.get("/{user_id}", response_model=User)
55
+ def read_user(user_id: int):
56
+ if user_id == 999:
57
+ raise HTTPException(status_code=404, detail="User not found")
58
+ return User(
59
+ id=user_id, name=f"User {user_id}", email=f"user{user_id}@example.com"
60
+ )
61
+
62
+ # --- v1 router ---
63
+ v1_router = routing.APIRouter(prefix="/v1")
64
+ v1_router.include_router(items_router)
65
+ v1_router.include_router(users_router, prefix="/u", tags=["v1-users"])
66
+
67
+ # --- app (created LAST) ---
68
+ app = FastAPI(title="Real World App")
69
+
70
+ @app.get("/", response_class=PlainTextResponse)
71
+ def root():
72
+ return "Hello World"
73
+
74
+ @app.get("/health")
75
+ def health():
76
+ return {"status": "ok"}
77
+
78
+ app.include_router(v1_router)
79
+ return app
80
+
81
+
82
+ def test_nested_routers_with_response_model_and_dependencies(monkeypatch):
83
+ apply_monkeypatch(monkeypatch)
84
+ app = _make_app()
85
+ client = TestClient(app)
86
+
87
+ # Direct app routes
88
+ resp = client.get("/")
89
+ assert resp.status_code == 200, resp.text
90
+ assert resp.text == "Hello World"
91
+
92
+ resp = client.get("/health")
93
+ assert resp.status_code == 200
94
+ assert resp.json() == {"status": "ok"}
95
+
96
+ # Nested router routes
97
+ resp = client.get("/v1/items/42")
98
+ assert resp.status_code == 200, resp.text
99
+ assert resp.json() == {"id": 42, "title": "Item 42", "owner_id": 1}
100
+
101
+ resp = client.post("/v1/items", json={"id": 1, "title": "Test", "owner_id": 2})
102
+ assert resp.status_code == 201, resp.text
103
+ assert resp.json() == {"id": 1, "title": "Test", "owner_id": 2}
104
+
105
+ resp = client.get("/v1/u/users/5")
106
+ assert resp.status_code == 200, resp.text
107
+ assert resp.json() == {"id": 5, "name": "User 5", "email": "user5@example.com"}
108
+
109
+ resp = client.get("/v1/u/users/999")
110
+ assert resp.status_code == 404, resp.text
111
+
112
+ # OpenAPI schema should be generatable
113
+ resp = client.get("/openapi.json")
114
+ assert resp.status_code == 200, resp.text
115
+ schema = resp.json()
116
+ assert "/v1/items/{item_id}" in schema["paths"]
117
+ assert "/v1/u/users/{user_id}" in schema["paths"]
118
+ assert "/" in schema["paths"]
119
+
120
+
121
+ def test_response_model_not_leaked_between_routes(monkeypatch):
122
+ """Regression test: response_model from one route must not leak to another.
123
+
124
+ The deferred cached_property closures were installed once per class,
125
+ capturing the first route's parameters for all subsequent routes.
126
+ """
127
+ apply_monkeypatch(monkeypatch)
128
+
129
+ # First route with response_model — its closures get installed on the class
130
+ router_a = routing.APIRouter()
131
+
132
+ @router_a.get("/a", response_model=Item)
133
+ def get_a():
134
+ return Item(id=1, title="A", owner_id=0)
135
+
136
+ # Second route WITHOUT response_model — should NOT inherit Item
137
+ router_b = routing.APIRouter()
138
+
139
+ @router_b.get("/b")
140
+ def get_b():
141
+ return {"plain": "dict"}
142
+
143
+ app = FastAPI()
144
+ app.include_router(router_a)
145
+ app.include_router(router_b)
146
+
147
+ # Check the original routes' response_model
148
+ for r in router_b.routes:
149
+ assert r.response_model is None, ( # ty: ignore[unresolved-attribute]
150
+ f"Route {r.path} incorrectly has response_model={r.response_model} " # ty: ignore[unresolved-attribute]
151
+ f"(leaked from another route)"
152
+ )
153
+
154
+ client = TestClient(app)
155
+ resp = client.get("/b")
156
+ assert resp.status_code == 200, resp.text
157
+ assert resp.json() == {"plain": "dict"}
158
+
159
+ # Also verify /a still works
160
+ resp = client.get("/a")
161
+ assert resp.status_code == 200, resp.text
162
+ assert resp.json() == {"id": 1, "title": "A", "owner_id": 0}
163
+
164
+
165
+ def test_custom_response_class_not_leaked(monkeypatch):
166
+ """Custom response_class on one route must not leak to others."""
167
+ apply_monkeypatch(monkeypatch)
168
+
169
+ router = routing.APIRouter()
170
+
171
+ @router.get("/html", response_class=PlainTextResponse)
172
+ def html():
173
+ return "<h1>hi</h1>"
174
+
175
+ @router.get("/json")
176
+ def json_route():
177
+ return {"ok": True}
178
+
179
+ app = FastAPI()
180
+ app.include_router(router)
181
+
182
+ client = TestClient(app)
183
+ resp = client.get("/html")
184
+ assert resp.status_code == 200
185
+ assert "text/plain" in resp.headers["content-type"]
186
+
187
+ resp = client.get("/json")
188
+ assert resp.status_code == 200
189
+ assert "application/json" in resp.headers["content-type"]
190
+ assert resp.json() == {"ok": True}
191
+
192
+
193
+ def test_dependency_overrides(monkeypatch):
194
+ apply_monkeypatch(monkeypatch)
195
+
196
+ def original_dep():
197
+ return "original"
198
+
199
+ def override_dep():
200
+ return "overridden"
201
+
202
+ router = routing.APIRouter()
203
+
204
+ @router.get("/dep")
205
+ def get_dep(val: t.Annotated[str, Depends(original_dep)]):
206
+ return {"val": val}
207
+
208
+ app = FastAPI()
209
+ app.include_router(router)
210
+ app.dependency_overrides[original_dep] = override_dep
211
+
212
+ client = TestClient(app)
213
+ resp = client.get("/dep")
214
+ assert resp.status_code == 200, resp.text
215
+ assert resp.json() == {"val": "overridden"}
216
+
217
+
218
+ def test_multiple_includes_same_router(monkeypatch):
219
+ apply_monkeypatch(monkeypatch)
220
+
221
+ router = routing.APIRouter()
222
+
223
+ @router.get("/ping")
224
+ def ping():
225
+ return {"pong": True}
226
+
227
+ app = FastAPI()
228
+ app.include_router(router, prefix="/api/v1")
229
+ app.include_router(router, prefix="/api/v2")
230
+
231
+ client = TestClient(app)
232
+ for prefix in ("/api/v1", "/api/v2"):
233
+ resp = client.get(f"{prefix}/ping")
234
+ assert resp.status_code == 200, f"{prefix}: {resp.text}"
235
+ assert resp.json() == {"pong": True}
236
+
237
+
238
+ def test_path_and_query_params(monkeypatch):
239
+ apply_monkeypatch(monkeypatch)
240
+
241
+ router = routing.APIRouter(prefix="/search")
242
+
243
+ @router.get("/{category}")
244
+ def search(
245
+ category: str,
246
+ q: str = Query(default=""),
247
+ limit: int = Query(default=10, le=100),
248
+ x_token: str = Header(default=""),
249
+ ):
250
+ return {"category": category, "q": q, "limit": limit, "token": x_token}
251
+
252
+ app = FastAPI()
253
+ app.include_router(router)
254
+
255
+ client = TestClient(app)
256
+ resp = client.get("/search/books?q=fastapi&limit=5", headers={"X-Token": "abc"})
257
+ assert resp.status_code == 200, resp.text
258
+ assert resp.json() == {
259
+ "category": "books",
260
+ "q": "fastapi",
261
+ "limit": 5,
262
+ "token": "abc",
263
+ }
264
+
265
+
266
+ def test_head_returns_405_like_upstream(monkeypatch):
267
+ """HEAD on a GET-only route returns 405 — same as unpatched FastAPI."""
268
+ apply_monkeypatch(monkeypatch)
269
+
270
+ router = routing.APIRouter()
271
+
272
+ @router.get("/auto")
273
+ def auto():
274
+ return {"ok": True}
275
+
276
+ app = FastAPI()
277
+ app.include_router(router)
278
+
279
+ client = TestClient(app)
280
+ resp = client.head("/auto")
281
+ assert resp.status_code == 405, resp.text
282
+
283
+
284
+ def test_exception_handlers(monkeypatch):
285
+ apply_monkeypatch(monkeypatch)
286
+
287
+ router = routing.APIRouter()
288
+
289
+ class CustomError(Exception):
290
+ pass
291
+
292
+ @router.get("/custom-error")
293
+ def raise_custom():
294
+ raise CustomError("something went wrong")
295
+
296
+ @router.get("/validation-error")
297
+ def cause_validation(q: int):
298
+ return {"q": q}
299
+
300
+ app = FastAPI()
301
+
302
+ @app.exception_handler(CustomError)
303
+ def custom_handler(request, exc):
304
+ return JSONResponse(status_code=418, content={"error": str(exc)})
305
+
306
+ app.include_router(router)
307
+
308
+ client = TestClient(app)
309
+ resp = client.get("/custom-error")
310
+ assert resp.status_code == 418, resp.text
311
+ assert resp.json() == {"error": "something went wrong"}
312
+
313
+ resp = client.get("/validation-error?q=notanint")
314
+ assert resp.status_code == 422, resp.text