Browse Source

♻️ Refactor internals to preserve `APIRouter` and `APIRoute` instances (#15745)

pull/15747/head
Sebastián Ramírez 2 weeks ago
committed by GitHub
parent
commit
8e1d774cef
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      docs/en/docs/advanced/openapi-callbacks.md
  2. 12
      docs/en/docs/advanced/path-operation-advanced-configuration.md
  3. 10
      docs/en/docs/how-to/extending-openapi.md
  4. 49
      docs/en/docs/release-notes.md
  5. 22
      docs/en/docs/tutorial/bigger-applications.md
  6. 22
      docs_src/path_operation_advanced_configuration/tutorial002_py310.py
  7. 5
      fastapi/applications.py
  8. 73
      fastapi/openapi/utils.py
  9. 1130
      fastapi/routing.py
  10. 11
      tests/test_custom_route_class.py
  11. 855
      tests/test_router_include_context.py

4
docs/en/docs/advanced/openapi-callbacks.md

@ -167,13 +167,13 @@ Notice how the callback URL used contains the URL received as a query parameter
At this point you have the *callback path operation(s)* needed (the one(s) that the *external developer* should implement in the *external API*) in the callback router you created above.
Now use the parameter `callbacks` in *your API's path operation decorator* to pass the attribute `.routes` (that's actually just a `list` of routes/*path operations*) from that callback router:
Now use the parameter `callbacks` in *your API's path operation decorator* to pass the attribute `.routes` from that callback router:
{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[33] *}
/// tip
Notice that you are not passing the router itself (`invoices_callback_router`) to `callback=`, but the attribute `.routes`, as in `invoices_callback_router.routes`.
Notice that you are not passing the router itself (`invoices_callback_router`) to `callbacks=`, but its `.routes`, as in `invoices_callback_router.routes`. FastAPI will use those routes to generate the callback OpenAPI documentation.
///

12
docs/en/docs/advanced/path-operation-advanced-configuration.md

@ -16,17 +16,11 @@ You would have to make sure that it is unique for each operation.
### Using the *path operation function* name as the operationId { #using-the-path-operation-function-name-as-the-operationid }
If you want to use your APIs' function names as `operationId`s, you can iterate over all of them and override each *path operation's* `operation_id` using their `APIRoute.name`.
If you want to use your APIs' function names as `operationId`s, you can pass a custom `generate_unique_id_function` to `FastAPI`.
You should do it after adding all your *path operations*.
The function receives each `APIRoute` and returns the `operationId` to use for that path operation.
{* ../../docs_src/path_operation_advanced_configuration/tutorial002_py310.py hl[2, 12:21, 24] *}
/// tip
If you manually call `app.openapi()`, you should update the `operationId`s before that.
///
{* ../../docs_src/path_operation_advanced_configuration/tutorial002_py310.py hl[2,5:6,9] *}
/// warning

10
docs/en/docs/how-to/extending-openapi.md

@ -25,7 +25,15 @@ And that function `get_openapi()` receives as parameters:
* `openapi_version`: The version of the OpenAPI specification used. By default, the latest: `3.1.0`.
* `summary`: A short summary of the API.
* `description`: The description of your API, this can include markdown and will be shown in the docs.
* `routes`: A list of routes, these are each of the registered *path operations*. They are taken from `app.routes`.
* `routes`: The routes from the application, taken from `app.routes`. FastAPI uses them to collect the registered *path operations*, including those from included routers.
/// tip | Technical Details
`app.routes` is a lower-level route tree. It can include route candidates that FastAPI uses internally for included routers, not only final `APIRoute` objects.
You can still pass `app.routes` to `get_openapi()`. FastAPI will traverse that route tree to collect the effective path operations.
///
/// note

49
docs/en/docs/release-notes.md

@ -7,6 +7,55 @@ hide:
## Latest Changes
♻️ Refactor internals to preserve `APIRouter` and `APIRoute` instances
Unblocks ✨ SO MANY THINGS ✨
Before this, `router.include_router(other_router)` would take each path operation from `other_router` and "clone" it, or recreate it from scratch.
This would mean that in the end there was only one top level router, part of the app.
The way it is structured here is that there are a few additional classes to handle intermediate metadata for router and route inclusion. That way the information of "router X includes Y and Y includes Z" is stored somewhere, without affecting (recreating / clonning) the final route.
#### Non Objectives
Dependencies for 404: previously I intended to support dependencies that would be executed even for 404, but that would conflict with the fact that a router could _not_ find a match, but the next router _did_ find a match. Executing dependencies in the router that did not find a match would not make sense, they could consume the request, body, etc. This original idea was discarded.
#### Breaking Changes
Now `router.routes` is no longer a plain list of `APIRoute` objects, it can contain these intermediate objects that can contain additional routers, forming a tree.
Any logic that depended on iterating on the `router.routes` directly would be affected, that logic cannot expect to be able to extract data from a plain list of routes, as it's no longer a plain list but a tree.
Additionally, any logic that iterated on `router.routes` to modify them would now also see these new objects, and would not see all the routes in the app.
`router.routes` should be considered an internal implementation detail, only passed around to the FastAPI functions that need it.
#### Features
* Adding routes (path operations) after a router is included now works, they are reflected as they are not copied.
* Including `subrouter` in `mainrouter` can be done before adding routes (path operations) to `subrouter`, because now the the entire object is stored instead of copying the routes.
* As routes are not copied, in some cases that might save some memory.
#### Alpha Features
This is not documented yet, so it's not officially supported yet and could change in the future.
But, as `APIRoute` and `APIRouter` instances are now preserved, they could be customized.
`APIRouter` has two new methods, `.matches()` and `.handle()`, counterpart to the existing ones in `APIRoute`. With this a router could customize how it matches and handles requests. For example, it could match only requests that include some specific header, for example for handling versions in headers.
Still, for now, consider this very experimental and potentially changing and breaking in the future.
#### Future Features Enabled
* Custom `APIRoute` subclasses (undocumented, but alraedy works as desccribed above)
* Custom `APIRouter` subclasses (undocumented, but already works as described above)
* Dependencies per router
* Exception handlers per router
* Middleware per router
* Other features planned
### Docs
* 📝 Update FastAPI Cloud deployment instructions. PR [#15724](https://github.com/fastapi/fastapi/pull/15724) by [@alejsdev](https://github.com/alejsdev).

22
docs/en/docs/tutorial/bigger-applications.md

@ -396,9 +396,9 @@ It will include all the routes from that router as part of it.
/// note | Technical Details
It will actually internally create a *path operation* for each *path operation* that was declared in the `APIRouter`.
FastAPI keeps the original `APIRouter` and its `APIRoute`s active when the router is included in the main application.
So, behind the scenes, it will actually work as if everything was the same single app.
That means custom `APIRouter` and `APIRoute` subclasses can still participate after the router is included.
///
@ -406,7 +406,7 @@ So, behind the scenes, it will actually work as if everything was the same singl
You don't have to worry about performance when including routers.
This will take microseconds and will only happen at startup.
This is designed to be lightweight and to avoid adding overhead to each request.
So it won't affect performance. ⚡
@ -461,7 +461,7 @@ The `APIRouter`s are not "mounted", they are not isolated from the rest of the a
This is because we want to include their *path operations* in the OpenAPI schema and the user interfaces.
As we cannot just isolate them and "mount" them independently of the rest, the *path operations* are "cloned" (re-created), not included directly.
FastAPI keeps the original routers and path operations active, and combines the router prefixes, dependencies, tags, responses, and other metadata when handling requests and generating OpenAPI.
///
@ -532,4 +532,16 @@ The same way you can include an `APIRouter` in a `FastAPI` application, you can
router.include_router(other_router)
```
Make sure you do it before including `router` in the `FastAPI` app, so that the *path operations* from `other_router` are also included.
You can do this before or after including `router` in the `FastAPI` app. FastAPI will still include the *path operations* from `other_router` in routing and OpenAPI.
The same applies to *path operations* added later to the routers. They will be visible through the earlier inclusion too.
/// warning | Technical Details
Avoid directly mutating `router.routes` after including a router. FastAPI treats router inclusion as live, so the original router and its routes remain part of routing and OpenAPI generation.
Use documented APIs such as path operation decorators and `.include_router()` to add routes and routers.
Treat `router.routes` as a lower-level route tree that can contain route definitions and included routers, and avoid relying on it as a flat list of final path operations.
///

22
docs_src/path_operation_advanced_configuration/tutorial002_py310.py

@ -1,24 +1,14 @@
from fastapi import FastAPI
from fastapi.routing import APIRoute
app = FastAPI()
@app.get("/items/")
async def read_items():
return [{"item_id": "Foo"}]
def custom_generate_unique_id(route: APIRoute) -> str:
return route.name
def use_route_names_as_operation_ids(app: FastAPI) -> None:
"""
Simplify operation IDs so that generated API clients have simpler function
names.
app = FastAPI(generate_unique_id_function=custom_generate_unique_id)
Should be called only after all routes have been added.
"""
for route in app.routes:
if isinstance(route, APIRoute):
route.operation_id = route.name # in this case, 'read_items'
use_route_names_as_operation_ids(app)
@app.get("/items/")
async def read_items():
return [{"item_id": "Foo"}]

5
fastapi/applications.py

@ -921,6 +921,7 @@ class FastAPI(Starlette):
),
] = "3.1.0"
self.openapi_schema: dict[str, Any] | None = None
self._openapi_routes_version: int | None = None
if self.openapi_url:
assert self.title, "A title must be provided for OpenAPI, e.g.: 'My API'"
assert self.version, "A version must be provided for OpenAPI, e.g.: '2.1.0'"
@ -1079,7 +1080,8 @@ class FastAPI(Starlette):
Read more in the
[FastAPI docs for OpenAPI](https://fastapi.tiangolo.com/how-to/extending-openapi/).
"""
if not self.openapi_schema:
routes_version = self.router._get_routes_version()
if not self.openapi_schema or self._openapi_routes_version != routes_version:
self.openapi_schema = get_openapi(
title=self.title,
version=self.version,
@ -1096,6 +1098,7 @@ class FastAPI(Starlette):
separate_input_output_schemas=self.separate_input_output_schemas,
external_docs=self.openapi_external_docs,
)
self._openapi_routes_version = routes_version
return self.openapi_schema
def setup(self) -> None:

73
fastapi/openapi/utils.py

@ -213,7 +213,7 @@ def get_openapi_operation_request_body(
def generate_operation_id(
*, route: routing.APIRoute, method: str
*, route: routing._APIRouteLike, method: str
) -> str: # pragma: nocover
warnings.warn(
message="fastapi.openapi.utils.generate_operation_id() was deprecated, "
@ -227,14 +227,14 @@ def generate_operation_id(
return generate_operation_id_for_path(name=route.name, path=path, method=method)
def generate_operation_summary(*, route: routing.APIRoute, method: str) -> str:
def generate_operation_summary(*, route: routing._APIRouteLike, method: str) -> str:
if route.summary:
return route.summary
return route.name.replace("_", " ").title()
def get_openapi_operation_metadata(
*, route: routing.APIRoute, method: str, operation_ids: set[str]
*, route: routing._APIRouteLike, method: str, operation_ids: set[str]
) -> dict[str, Any]:
operation: dict[str, Any] = {}
if route.tags:
@ -259,7 +259,7 @@ def get_openapi_operation_metadata(
def get_openapi_path(
*,
route: routing.APIRoute,
route: routing._APIRouteLike,
operation_ids: set[str],
model_name_map: ModelNameMap,
field_mapping: dict[
@ -329,7 +329,7 @@ def get_openapi_path(
cb_security_schemes,
cb_definitions,
) = get_openapi_path(
route=callback,
route=cast(routing._APIRouteLike, callback),
operation_ids=operation_ids,
model_name_map=model_name_map,
field_mapping=field_mapping,
@ -478,6 +478,18 @@ def get_openapi_path(
return path, security_schemes, definitions
def _get_api_route_for_openapi(
route: BaseRoute, route_context: routing._EffectiveRouteContext | None
) -> routing._APIRouteLike | None:
if route_context is not None and isinstance(
route_context.original_route, routing.APIRoute
):
return cast(routing._APIRouteLike, route_context)
if isinstance(route, routing.APIRoute):
return cast(routing._APIRouteLike, route)
return None
def get_fields_from_routes(
routes: Sequence[BaseRoute],
) -> list[ModelField]:
@ -485,24 +497,25 @@ def get_fields_from_routes(
responses_from_routes: list[ModelField] = []
request_fields_from_routes: list[ModelField] = []
callback_flat_models: list[ModelField] = []
for route in routes:
if not isinstance(route, routing.APIRoute):
for route, route_context in routing._iter_routes_with_context(routes):
api_route = _get_api_route_for_openapi(route, route_context)
if api_route is None:
continue
if route.include_in_schema:
if route.body_field:
assert isinstance(route.body_field, ModelField), (
if api_route.include_in_schema:
if api_route.body_field:
assert isinstance(api_route.body_field, ModelField), (
"A request body must be a Pydantic Field"
)
body_fields_from_routes.append(route.body_field)
if route.response_field:
responses_from_routes.append(route.response_field)
if route.response_fields:
responses_from_routes.extend(route.response_fields.values())
if route.stream_item_field:
responses_from_routes.append(route.stream_item_field)
if route.callbacks:
callback_flat_models.extend(get_fields_from_routes(route.callbacks))
params = get_flat_params(route.dependant)
body_fields_from_routes.append(api_route.body_field)
if api_route.response_field:
responses_from_routes.append(api_route.response_field)
if api_route.response_fields:
responses_from_routes.extend(api_route.response_fields.values())
if api_route.stream_item_field:
responses_from_routes.append(api_route.stream_item_field)
if api_route.callbacks:
callback_flat_models.extend(get_fields_from_routes(api_route.callbacks))
params = get_flat_params(api_route.dependant)
request_fields_from_routes.extend(params)
flat_models = callback_flat_models + list(
@ -546,7 +559,7 @@ def get_openapi(
paths: dict[str, dict[str, Any]] = {}
webhook_paths: dict[str, dict[str, Any]] = {}
operation_ids: set[str] = set()
all_fields = get_fields_from_routes(list(routes or []) + list(webhooks or []))
all_fields = get_fields_from_routes(list(routes) + list(webhooks or []))
flat_models = get_flat_models_from_fields(all_fields, known_models=set())
model_name_map = get_model_name_map(flat_models)
field_mapping, definitions = get_definitions(
@ -554,10 +567,11 @@ def get_openapi(
model_name_map=model_name_map,
separate_input_output_schemas=separate_input_output_schemas,
)
for route in routes or []:
if isinstance(route, routing.APIRoute):
for route, route_context in routing._iter_routes_with_context(routes):
api_route = _get_api_route_for_openapi(route, route_context)
if api_route is not None:
result = get_openapi_path(
route=route,
route=api_route,
operation_ids=operation_ids,
model_name_map=model_name_map,
field_mapping=field_mapping,
@ -566,17 +580,18 @@ def get_openapi(
if result:
path, security_schemes, path_definitions = result
if path:
paths.setdefault(route.path_format, {}).update(path)
paths.setdefault(api_route.path_format, {}).update(path)
if security_schemes:
components.setdefault("securitySchemes", {}).update(
security_schemes
)
if path_definitions:
definitions.update(path_definitions)
for webhook in webhooks or []:
if isinstance(webhook, routing.APIRoute):
for webhook, webhook_context in routing._iter_routes_with_context(webhooks or []):
api_webhook = _get_api_route_for_openapi(webhook, webhook_context)
if api_webhook is not None:
result = get_openapi_path(
route=webhook,
route=api_webhook,
operation_ids=operation_ids,
model_name_map=model_name_map,
field_mapping=field_mapping,
@ -585,7 +600,7 @@ def get_openapi(
if result:
path, security_schemes, path_definitions = result
if path:
webhook_paths.setdefault(webhook.path_format, {}).update(path)
webhook_paths.setdefault(api_webhook.path_format, {}).update(path)
if security_schemes:
components.setdefault("securitySchemes", {}).update(
security_schemes

1130
fastapi/routing.py

File diff suppressed because it is too large

11
tests/test_custom_route_class.py

@ -3,7 +3,6 @@ from fastapi import APIRouter, FastAPI
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from starlette.routing import Route
app = FastAPI()
@ -63,13 +62,9 @@ def test_get_path(path, expected_status, expected_response):
def test_route_classes():
routes = {}
for r in app.router.routes:
assert isinstance(r, Route)
routes[r.path] = r
assert getattr(routes["/a/"], "x_type") == "A" # noqa: B009
assert getattr(routes["/a/b/"], "x_type") == "B" # noqa: B009
assert getattr(routes["/a/b/c/"], "x_type") == "C" # noqa: B009
assert isinstance(router_a.routes[0], APIRouteA)
assert isinstance(router_b.routes[0], APIRouteB)
assert isinstance(router_c.routes[0], APIRouteC)
def test_openapi_schema():

855
tests/test_router_include_context.py

@ -0,0 +1,855 @@
from typing import Annotated, cast
import pytest
from fastapi import APIRouter, Body, Depends, FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.routing import (
APIRoute,
_IncludedRouter,
_iter_included_route_candidates,
_restore_fastapi_scope_key,
)
from fastapi.testclient import TestClient
from starlette.routing import BaseRoute, Host, Match, Mount, NoMatchFound, Route, Router
def dependency_a():
return "a"
def dependency_b():
return "b"
def dependency_c():
return "c"
def unique_id_b(route: APIRoute) -> str:
return f"b_{route.name}"
def test_router_include_context_matches_flattened_include_metadata():
callback_router = APIRouter()
@callback_router.post("/callback")
def callback(): # pragma: no cover
return {"ok": True}
callback_route = callback_router.routes[0]
parent_router = APIRouter()
included_router = APIRouter(
prefix="/items",
tags=["router"],
dependencies=[Depends(dependency_a)],
responses={401: {"description": "Unauthorized"}},
callbacks=[callback_route],
default_response_class=HTMLResponse,
strict_content_type=False,
)
@included_router.get(
"/{item_id}",
tags=["route"],
dependencies=[Depends(dependency_b)],
responses={404: {"description": "Missing"}},
callbacks=[callback_route],
generate_unique_id_function=unique_id_b,
)
def read_item(item_id: str, request: Request):
context = request.scope["fastapi"]["effective_route_context"]
return JSONResponse(
{
"path": context.path,
"tags": context.tags,
"dependency_count": len(context.dependencies),
"response_codes": sorted(context.responses),
"callback_count": len(context.callbacks or []),
"deprecated": context.deprecated,
"include_in_schema": context.include_in_schema,
"response_class": context.response_class.__name__,
"generate_unique_id": context.generate_unique_id_function(context),
"strict_content_type": context.strict_content_type,
"has_dependency_overrides_provider": (
context.dependency_overrides_provider
is app.router.dependency_overrides_provider
),
}
)
parent_router.include_router(
included_router,
prefix="/api",
tags=["include"],
dependencies=[Depends(dependency_c)],
responses={400: {"description": "Bad request"}},
callbacks=[callback_route],
deprecated=True,
include_in_schema=False,
)
app = FastAPI()
app.include_router(parent_router)
response = TestClient(app).get("/api/items/foo")
assert response.status_code == 200
assert response.json() == {
"path": "/api/items/{item_id}",
"tags": ["include", "router", "route"],
"dependency_count": 3,
"response_codes": [400, 401, 404],
"callback_count": 3,
"deprecated": True,
"include_in_schema": False,
"response_class": "HTMLResponse",
"generate_unique_id": "b_read_item",
"strict_content_type": False,
"has_dependency_overrides_provider": True,
}
def test_live_route_addition_uses_include_metadata_for_runtime_and_openapi():
calls: list[str] = []
def included_dependency():
calls.append("dependency")
router = APIRouter()
app = FastAPI()
app.include_router(
router,
prefix="/api",
tags=["included"],
dependencies=[Depends(included_dependency)],
responses={418: {"description": "Teapot"}},
)
@router.get("/later")
def read_later():
return {"later": True}
client = TestClient(app)
response = client.get("/api/later")
assert response.status_code == 200
assert response.json() == {"later": True}
assert calls == ["dependency"]
operation = client.get("/openapi.json").json()["paths"]["/api/later"]["get"]
assert operation["tags"] == ["included"]
assert operation["responses"]["418"] == {"description": "Teapot"}
def test_openapi_cache_updates_after_live_route_addition():
router = APIRouter()
app = FastAPI()
app.include_router(router, prefix="/api")
client = TestClient(app)
first_schema = client.get("/openapi.json").json()
assert "/api/later" not in first_schema["paths"]
@router.get("/later")
def read_later(): # pragma: no cover
return {"later": True}
second_schema = client.get("/openapi.json").json()
assert "/api/later" in second_schema["paths"]
def test_nested_router_added_after_parent_inclusion_is_live():
parent_router = APIRouter()
child_router = APIRouter()
app = FastAPI()
app.include_router(parent_router, prefix="/api")
parent_router.include_router(child_router, prefix="/child", tags=["child"])
@child_router.get("/items")
def read_items():
return ["item"]
client = TestClient(app)
response = client.get("/api/child/items")
assert response.status_code == 200
assert response.json() == ["item"]
operation = client.get("/openapi.json").json()["paths"]["/api/child/items"]["get"]
assert operation["tags"] == ["child"]
def test_repeated_deep_inclusions_handle_all_concrete_paths():
shared_router = APIRouter()
@shared_router.get("/items")
def read_items():
return []
parent_router = APIRouter()
parent_router.include_router(shared_router, prefix="/a")
parent_router.include_router(shared_router, prefix="/b")
app = FastAPI()
app.include_router(parent_router, prefix="/v1")
app.include_router(parent_router, prefix="/v2")
client = TestClient(app)
paths = ["/v1/a/items", "/v1/b/items", "/v2/a/items", "/v2/b/items"]
for path in paths:
response = client.get(path)
assert response.status_code == 200
assert response.json() == []
assert set(client.get("/openapi.json").json()["paths"]) == set(paths)
def test_url_path_for_uses_effective_context_for_live_included_route():
router = APIRouter()
app = FastAPI()
app.include_router(router, prefix="/api")
@router.get("/items/{item_id}", name="read_item")
def read_item(item_id: str): # pragma: no cover
return {"item_id": item_id}
assert app.url_path_for("read_item", item_id="abc") == "/api/items/abc"
def test_url_path_for_uses_distinct_repeated_inclusion_contexts():
router = APIRouter()
@router.get("/items/{item_id}", name="read_item")
def read_item(item_id: str): # pragma: no cover
return {"item_id": item_id}
parent_router = APIRouter()
parent_router.include_router(router, prefix="/v1")
parent_router.include_router(router, prefix="/v2")
assert parent_router.url_path_for("read_item", item_id="abc") == "/v1/items/abc"
assert (
parent_router.routes[1].url_path_for("read_item", item_id="abc")
== "/v2/items/abc"
)
def test_indirect_router_inclusion_cycles_are_rejected():
parent_router = APIRouter()
child_router = APIRouter()
parent_router.include_router(child_router, prefix="/child")
with pytest.raises(AssertionError, match="already includes this router"):
child_router.include_router(parent_router, prefix="/parent")
parent_router = APIRouter()
child_router = APIRouter()
grandchild_router = APIRouter()
parent_router.include_router(child_router, prefix="/child")
child_router.include_router(grandchild_router, prefix="/grandchild")
with pytest.raises(AssertionError, match="already includes this router"):
grandchild_router.include_router(parent_router, prefix="/parent")
def test_original_api_route_subclass_instance_is_called_after_inclusion():
class TrackingRoute(APIRoute):
calls = 0
async def handle(self, scope, receive, send):
self.calls += 1
await super().handle(scope, receive, send)
router = APIRouter(route_class=TrackingRoute)
@router.get("/items")
def read_items():
return []
original_route = router.routes[0]
assert isinstance(original_route, TrackingRoute)
app = FastAPI()
app.include_router(router, prefix="/api")
response = TestClient(app).get("/api/items")
assert response.status_code == 200
assert original_route.calls == 1
def test_original_api_route_get_route_handler_is_called_after_inclusion():
class TrackingRoute(APIRoute):
calls = 0
def get_route_handler(self):
handler = super().get_route_handler()
async def custom_handler(request):
self.calls += 1
return await handler(request)
return custom_handler
router = APIRouter(route_class=TrackingRoute)
@router.get("/items")
def read_items():
return []
original_route = router.routes[0]
assert isinstance(original_route, TrackingRoute)
original_route.calls = 0
app = FastAPI()
app.include_router(router, prefix="/api")
response = TestClient(app).get("/api/items")
assert response.status_code == 200
assert original_route.calls == 1
def test_original_api_route_matches_is_called_after_inclusion():
class HeaderRoute(APIRoute):
calls = 0
def matches(self, scope):
self.calls += 1
headers = dict(scope.get("headers", []))
if headers.get(b"x-match") != b"yes":
return Match.NONE, {}
return super().matches(scope)
router = APIRouter(route_class=HeaderRoute)
@router.get("/items")
def read_items():
return []
original_route = router.routes[0]
assert isinstance(original_route, HeaderRoute)
original_route.calls = 0
app = FastAPI()
app.include_router(router, prefix="/api")
client = TestClient(app)
assert client.get("/api/items").status_code == 404
assert client.get("/api/items", headers={"x-match": "yes"}).status_code == 200
assert original_route.calls >= 2
def test_effective_route_context_is_available_in_scope_during_request():
router = APIRouter()
@router.get("/items")
def read_items(request: Request):
fastapi_scope = request.scope.get("fastapi")
assert isinstance(fastapi_scope, dict)
return {
"has_context": "effective_route_context" in fastapi_scope,
"path": fastapi_scope["effective_route_context"].path,
}
app = FastAPI()
app.include_router(router, prefix="/api")
response = TestClient(app).get("/api/items")
assert response.status_code == 200
assert response.json() == {"has_context": True, "path": "/api/items"}
def test_original_api_router_matches_is_called_after_inclusion():
class HeaderRouter(APIRouter):
calls = 0
def matches(self, scope):
self.calls += 1
headers = dict(scope.get("headers", []))
if headers.get(b"x-router-match") != b"yes":
return Match.NONE, {}
return super().matches(scope)
router = HeaderRouter()
@router.get("/items")
def read_items():
return []
app = FastAPI()
app.include_router(router, prefix="/api")
client = TestClient(app)
assert client.get("/api/items").status_code == 404
assert (
client.get("/api/items", headers={"x-router-match": "yes"}).status_code == 200
)
assert router.calls >= 2
def test_original_nested_api_router_subclasses_are_called_after_inclusion():
class TrackingRouter(APIRouter):
calls = 0
async def handle(self, scope, receive, send):
self.calls += 1
await super().handle(scope, receive, send)
parent_router = TrackingRouter()
child_router = TrackingRouter()
@child_router.get("/items")
def read_items():
return []
parent_router.include_router(child_router, prefix="/child")
app = FastAPI()
app.include_router(parent_router, prefix="/api")
response = TestClient(app).get("/api/child/items")
assert response.status_code == 200
assert parent_router.calls == 1
assert child_router.calls == 1
def test_router_and_include_prefix_path_params_reach_endpoint_and_openapi():
router = APIRouter(prefix="/tenants/{tenant_id}")
@router.get("/items/{item_id}")
def read_item(version: int, tenant_id: int, item_id: int):
return {"version": version, "tenant_id": tenant_id, "item_id": item_id}
app = FastAPI()
app.include_router(router, prefix="/api/{version}")
client = TestClient(app)
response = client.get("/api/1/tenants/2/items/3")
assert response.status_code == 200
assert response.json() == {"version": 1, "tenant_id": 2, "item_id": 3}
operation = client.get("/openapi.json").json()["paths"][
"/api/{version}/tenants/{tenant_id}/items/{item_id}"
]["get"]
assert {parameter["name"] for parameter in operation["parameters"]} == {
"version",
"tenant_id",
"item_id",
}
def test_effective_body_fields_from_app_router_include_and_route_match_openapi():
def app_body_dependency(app_body: Annotated[str, Body()]):
return app_body
def router_body_dependency(router_body: Annotated[int, Body()]):
return router_body
def include_body_dependency(include_body: Annotated[bool, Body()]):
return include_body
app = FastAPI(dependencies=[Depends(app_body_dependency)])
router = APIRouter(dependencies=[Depends(router_body_dependency)])
@router.post("/items")
def create_item(route_body: Annotated[float, Body()]):
return {"route_body": route_body}
app.include_router(
router,
prefix="/api",
dependencies=[Depends(include_body_dependency)],
)
client = TestClient(app)
response = client.post(
"/api/items",
json={
"app_body": "app",
"router_body": 1,
"include_body": True,
"route_body": 2.5,
},
)
assert response.status_code == 200
assert response.json() == {"route_body": 2.5}
schema = client.get("/openapi.json").json()
request_body_schema = schema["paths"]["/api/items"]["post"]["requestBody"][
"content"
]["application/json"]["schema"]
body_ref = request_body_schema["$ref"].removeprefix("#/components/schemas/")
body_schema = schema["components"]["schemas"][body_ref]
assert set(body_schema["required"]) == {
"app_body",
"router_body",
"include_body",
"route_body",
}
assert set(body_schema["properties"]) == {
"app_body",
"router_body",
"include_body",
"route_body",
}
def test_later_full_match_wins_over_earlier_included_partial_match():
get_router = APIRouter()
post_router = APIRouter()
@get_router.get("/items")
def read_items(): # pragma: no cover
return {"method": "get"}
@post_router.post("/items")
def create_item():
return {"method": "post"}
app = FastAPI()
app.include_router(get_router, prefix="/api")
app.include_router(post_router, prefix="/api")
response = TestClient(app).post("/api/items")
assert response.status_code == 200
assert response.json() == {"method": "post"}
def test_included_partial_match_returns_405_when_no_later_full_match_exists():
router = APIRouter()
@router.get("/items")
def read_items(): # pragma: no cover
return []
app = FastAPI()
app.include_router(router, prefix="/api")
response = TestClient(app).post("/api/items")
assert response.status_code == 405
assert response.headers["allow"] == "GET"
def test_included_slash_redirect_does_not_block_later_exact_match():
redirect_router = APIRouter()
exact_router = APIRouter()
@redirect_router.get("/items/")
def read_items_with_slash(): # pragma: no cover
return {"path": "slash"}
@exact_router.get("/items")
def read_items_without_slash():
return {"path": "exact"}
app = FastAPI()
app.include_router(redirect_router, prefix="/api")
app.include_router(exact_router, prefix="/api")
response = TestClient(app).get("/api/items", follow_redirects=False)
assert response.status_code == 200
assert response.json() == {"path": "exact"}
def test_failed_included_match_does_not_leak_effective_context_to_later_route():
class RejectingRoute(APIRoute):
def matches(self, scope):
return Match.NONE, {}
rejecting_router = APIRouter(route_class=RejectingRoute)
fallback_router = APIRouter()
@rejecting_router.get("/items")
def rejected_item(): # pragma: no cover
return {"source": "rejected"}
@fallback_router.get("/items")
def fallback_item(request: Request):
fastapi_scope = request.scope.get("fastapi", {})
context = fastapi_scope.get("effective_route_context")
return {
"source": "fallback",
"context_path": getattr(context, "path", None),
}
app = FastAPI()
app.include_router(rejecting_router, prefix="/api")
app.include_router(fallback_router, prefix="/api")
response = TestClient(app).get("/api/items")
assert response.status_code == 200
assert response.json() == {"source": "fallback", "context_path": "/api/items"}
def test_included_starlette_mount_keeps_prefix_runtime_and_url_path_for():
def mounted_endpoint(request):
return PlainTextResponse("mounted")
router = APIRouter(
routes=[
Mount(
"/mounted",
routes=[Route("/items/{item_id}", mounted_endpoint, name="read_item")],
name="mounted",
)
]
)
app = FastAPI()
app.include_router(router, prefix="/api")
client = TestClient(app)
response = client.get("/api/mounted/items/abc")
assert response.status_code == 200
assert response.text == "mounted"
assert (
app.url_path_for("mounted:read_item", item_id="abc") == "/api/mounted/items/abc"
)
def test_included_starlette_host_keeps_prefix_runtime_and_url_path_for():
def hosted_endpoint(request):
return PlainTextResponse("hosted")
hosted_app = Router(
routes=[Route("/items/{item_id}", hosted_endpoint, name="read_item")]
)
router = APIRouter(
routes=[Host("{subdomain}.example.com", hosted_app, name="hosted")]
)
app = FastAPI()
app.include_router(router, prefix="/api")
client = TestClient(app, base_url="http://api.example.com")
response = client.get("/api/items/abc")
assert response.status_code == 200
assert response.text == "hosted"
url = app.url_path_for("hosted:read_item", subdomain="api", item_id="abc")
assert str(url) == "/api/items/abc"
assert url.host == "api.example.com"
def test_restore_fastapi_scope_key_ignores_non_dict_fastapi_scope():
scope = {"fastapi": "not-a-dict"}
_restore_fastapi_scope_key(scope, "effective_route_context", object())
assert scope == {"fastapi": "not-a-dict"}
@pytest.mark.anyio
async def test_included_api_route_without_app_scope_returns_405_response():
router = APIRouter()
@router.get("/items")
def read_items(): # pragma: no cover
return {"items": []}
app = FastAPI()
app.include_router(router, prefix="/api")
included_router = cast(_IncludedRouter, app.router.routes[-1])
effective_context = next(included_router.effective_route_contexts())
route = effective_context.original_route
messages = []
async def receive(): # pragma: no cover
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
messages.append(message)
scope = {
"type": "http",
"method": "POST",
"path": "/api/items",
"raw_path": b"/api/items",
"root_path": "",
"scheme": "http",
"query_string": b"",
"headers": [],
"fastapi": {"effective_route_context": effective_context},
}
await route.handle(scope, receive, send)
assert messages[0]["type"] == "http.response.start"
assert messages[0]["status"] == 405
assert dict(messages[0]["headers"])[b"allow"] == b"GET"
def test_effective_api_route_context_does_not_match_websocket_scope():
router = APIRouter()
@router.get("/items")
def read_items(): # pragma: no cover
return {"items": []}
app = FastAPI()
app.include_router(router, prefix="/api")
included_router = cast(_IncludedRouter, app.router.routes[-1])
effective_context = next(included_router.effective_route_contexts())
match, child_scope = effective_context.matches(
{
"type": "websocket",
"path": "/api/items",
"root_path": "",
}
)
assert match == Match.NONE
assert child_scope == {}
def test_effective_api_route_context_url_path_for_no_match():
router = APIRouter()
@router.get("/items/{item_id}")
def read_item(item_id: str): # pragma: no cover
return {"item_id": item_id}
app = FastAPI()
app.include_router(router, prefix="/api")
included_router = cast(_IncludedRouter, app.router.routes[-1])
effective_context = next(included_router.effective_route_contexts())
with pytest.raises(NoMatchFound):
effective_context.url_path_for("missing", item_id="abc")
with pytest.raises(NoMatchFound):
included_router.url_path_for("missing", item_id="abc")
def test_included_starlette_host_without_prefix_keeps_original_app():
def hosted_endpoint(request):
return PlainTextResponse("hosted")
hosted_app = Router(
routes=[Route("/items/{item_id}", hosted_endpoint, name="read_item")]
)
router = APIRouter(
routes=[Host("{subdomain}.example.com", hosted_app, name="hosted")]
)
app = FastAPI()
app.include_router(router)
client = TestClient(app, base_url="http://api.example.com")
response = client.get("/items/abc")
assert response.status_code == 200
assert response.text == "hosted"
class UnknownRoute(BaseRoute):
def matches(self, scope): # pragma: no cover
return Match.NONE, {}
async def handle(self, scope, receive, send): # pragma: no cover
raise AssertionError("UnknownRoute should not be handled")
def url_path_for(self, name, /, **path_params): # pragma: no cover
raise NoMatchFound(name, path_params)
@pytest.mark.anyio
async def test_included_unknown_route_is_ignored_and_can_return_default_404():
router = APIRouter(routes=[UnknownRoute()])
app = FastAPI()
app.include_router(router, prefix="/api")
included_router = cast(_IncludedRouter, app.router.routes[-1])
assert included_router.effective_candidates() == []
messages = []
async def receive(): # pragma: no cover
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
messages.append(message)
scope = {
"type": "http",
"method": "GET",
"path": "/api/missing",
"raw_path": b"/api/missing",
"root_path": "",
"scheme": "http",
"query_string": b"",
"headers": [],
"fastapi": {},
}
await included_router._handle_selected(scope, receive, send)
assert messages[0]["type"] == "http.response.start"
assert messages[0]["status"] == 404
def test_no_prefix_include_validation_sees_effective_starlette_route_candidates():
def endpoint(request): # pragma: no cover
return PlainTextResponse("ok")
child_router = APIRouter(routes=[Route("/items", endpoint, name="read_items")])
parent_router = APIRouter()
parent_router.include_router(child_router, prefix="/child")
candidates = list(_iter_included_route_candidates(parent_router.routes))
assert cast(Route, candidates[0]).path == "/child/items"
def test_apirouter_matches_fallback_without_include_context():
router = APIRouter()
def read_items(request): # pragma: no cover
return PlainTextResponse("items")
router.add_route("/items", read_items)
assert router.matches({"type": "http", "path": "/items", "root_path": ""}) == (
Match.NONE,
{},
)
@pytest.mark.anyio
async def test_apirouter_handle_fallback_without_include_context():
router = APIRouter()
def read_items(request):
return PlainTextResponse("items")
router.add_route("/items", read_items)
messages = []
async def receive(): # pragma: no cover
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
messages.append(message)
scope = {
"type": "http",
"method": "GET",
"path": "/items",
"raw_path": b"/items",
"root_path": "",
"scheme": "http",
"query_string": b"",
"headers": [],
}
await router.handle(scope, receive, send)
assert messages[0]["type"] == "http.response.start"
assert messages[0]["status"] == 200
assert messages[1]["body"] == b"items"
Loading…
Cancel
Save