Browse Source
Extract APIRouter, APIRoute/APIWebSocketRoute, and handler/util logic into focused modules while preserving `fastapi.routing` public imports via compatibility re-exports and adding routing facade contract tests. Made-with: Cursorpull/15210/head
7 changed files with 5148 additions and 4956 deletions
@ -0,0 +1,27 @@ |
|||||
|
# Routing Architecture |
||||
|
|
||||
|
FastAPI keeps public routing imports stable at `fastapi.routing`, but the |
||||
|
implementation is split into focused internal modules for maintainability. |
||||
|
|
||||
|
## Public facade |
||||
|
|
||||
|
- `fastapi.routing` |
||||
|
- compatibility facade and stable imports |
||||
|
- re-exports core symbols such as `APIRouter`, `APIRoute`, and helper hooks |
||||
|
|
||||
|
## Internal modules |
||||
|
|
||||
|
- `fastapi.routing_router` |
||||
|
- `APIRouter` implementation |
||||
|
- `fastapi.routing_routes` |
||||
|
- `APIRoute` and `APIWebSocketRoute` |
||||
|
- `fastapi.routing_handlers` |
||||
|
- request/websocket handler factories and handler config objects |
||||
|
- `fastapi.routing_utils` |
||||
|
- low-level shared utilities (response serialization, lifespan helpers, wrappers) |
||||
|
|
||||
|
## Stability contract |
||||
|
|
||||
|
- Application code should import from `fastapi.routing` (or top-level `fastapi`). |
||||
|
- Internal modules are implementation details and may evolve. |
||||
|
- Compatibility tests validate that key re-exports remain stable. |
||||
File diff suppressed because it is too large
@ -0,0 +1,464 @@ |
|||||
|
import email.message |
||||
|
import json |
||||
|
from collections.abc import AsyncIterator, Callable, Coroutine, Iterator |
||||
|
from contextlib import AsyncExitStack, asynccontextmanager |
||||
|
from dataclasses import dataclass, field |
||||
|
from typing import Any, cast |
||||
|
|
||||
|
import anyio |
||||
|
from anyio.abc import ObjectReceiveStream |
||||
|
from fastapi import params |
||||
|
from fastapi._compat import ModelField, Undefined, lenient_issubclass |
||||
|
from fastapi.datastructures import Default, DefaultPlaceholder |
||||
|
from fastapi.dependencies.models import Dependant |
||||
|
from fastapi.dependencies.utils import solve_dependencies |
||||
|
from fastapi.encoders import jsonable_encoder |
||||
|
from fastapi.exceptions import ( |
||||
|
EndpointContext, |
||||
|
RequestValidationError, |
||||
|
ResponseValidationError, |
||||
|
WebSocketRequestValidationError, |
||||
|
) |
||||
|
from fastapi.routing_utils import ( |
||||
|
build_response_args, |
||||
|
extract_endpoint_context, |
||||
|
run_endpoint_function, |
||||
|
serialize_response, |
||||
|
) |
||||
|
from fastapi.sse import ( |
||||
|
KEEPALIVE_COMMENT, |
||||
|
EventSourceResponse, |
||||
|
ServerSentEvent, |
||||
|
format_sse_event, |
||||
|
) |
||||
|
from fastapi.types import IncEx |
||||
|
from fastapi.utils import is_body_allowed_for_status_code |
||||
|
from starlette.concurrency import iterate_in_threadpool |
||||
|
from starlette.datastructures import FormData |
||||
|
from starlette.exceptions import HTTPException |
||||
|
from starlette.requests import Request |
||||
|
from starlette.responses import JSONResponse, Response, StreamingResponse |
||||
|
from starlette.websockets import WebSocket |
||||
|
|
||||
|
|
||||
|
def _get_ping_interval() -> float: |
||||
|
# Keep compatibility with tests/users that patch fastapi.routing._PING_INTERVAL. |
||||
|
# If not present, fall back to the default defined in fastapi.sse. |
||||
|
from fastapi import routing as routing_module |
||||
|
from fastapi.sse import _PING_INTERVAL as default_ping_interval |
||||
|
|
||||
|
return float(getattr(routing_module, "_PING_INTERVAL", default_ping_interval)) |
||||
|
|
||||
|
|
||||
|
@dataclass |
||||
|
class RouteHandlerConfig: |
||||
|
dependant: Dependant |
||||
|
body_field: ModelField | None = None |
||||
|
status_code: int | None = None |
||||
|
response_class: type[Response] | DefaultPlaceholder = field( |
||||
|
default_factory=lambda: Default(JSONResponse) |
||||
|
) |
||||
|
response_field: ModelField | None = None |
||||
|
response_model_include: IncEx | None = None |
||||
|
response_model_exclude: IncEx | None = None |
||||
|
response_model_by_alias: bool = True |
||||
|
response_model_exclude_unset: bool = False |
||||
|
response_model_exclude_defaults: bool = False |
||||
|
response_model_exclude_none: bool = False |
||||
|
dependency_overrides_provider: Any | None = None |
||||
|
embed_body_fields: bool = False |
||||
|
strict_content_type: bool | DefaultPlaceholder = field( |
||||
|
default_factory=lambda: Default(True) |
||||
|
) |
||||
|
stream_item_field: ModelField | None = None |
||||
|
is_json_stream: bool = False |
||||
|
|
||||
|
|
||||
|
def get_request_handler( |
||||
|
dependant: Dependant | RouteHandlerConfig, |
||||
|
body_field: ModelField | None = None, |
||||
|
status_code: int | None = None, |
||||
|
response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), |
||||
|
response_field: ModelField | None = None, |
||||
|
response_model_include: IncEx | None = None, |
||||
|
response_model_exclude: IncEx | None = None, |
||||
|
response_model_by_alias: bool = True, |
||||
|
response_model_exclude_unset: bool = False, |
||||
|
response_model_exclude_defaults: bool = False, |
||||
|
response_model_exclude_none: bool = False, |
||||
|
dependency_overrides_provider: Any | None = None, |
||||
|
embed_body_fields: bool = False, |
||||
|
strict_content_type: bool | DefaultPlaceholder = Default(True), |
||||
|
stream_item_field: ModelField | None = None, |
||||
|
is_json_stream: bool = False, |
||||
|
) -> Callable[[Request], Coroutine[Any, Any, Response]]: |
||||
|
if isinstance(dependant, RouteHandlerConfig): |
||||
|
config = dependant |
||||
|
else: |
||||
|
config = RouteHandlerConfig( |
||||
|
dependant=dependant, |
||||
|
body_field=body_field, |
||||
|
status_code=status_code, |
||||
|
response_class=response_class, |
||||
|
response_field=response_field, |
||||
|
response_model_include=response_model_include, |
||||
|
response_model_exclude=response_model_exclude, |
||||
|
response_model_by_alias=response_model_by_alias, |
||||
|
response_model_exclude_unset=response_model_exclude_unset, |
||||
|
response_model_exclude_defaults=response_model_exclude_defaults, |
||||
|
response_model_exclude_none=response_model_exclude_none, |
||||
|
dependency_overrides_provider=dependency_overrides_provider, |
||||
|
embed_body_fields=embed_body_fields, |
||||
|
strict_content_type=strict_content_type, |
||||
|
stream_item_field=stream_item_field, |
||||
|
is_json_stream=is_json_stream, |
||||
|
) |
||||
|
dependant = config.dependant |
||||
|
body_field = config.body_field |
||||
|
status_code = config.status_code |
||||
|
response_class = config.response_class |
||||
|
response_field = config.response_field |
||||
|
response_model_include = config.response_model_include |
||||
|
response_model_exclude = config.response_model_exclude |
||||
|
response_model_by_alias = config.response_model_by_alias |
||||
|
response_model_exclude_unset = config.response_model_exclude_unset |
||||
|
response_model_exclude_defaults = config.response_model_exclude_defaults |
||||
|
response_model_exclude_none = config.response_model_exclude_none |
||||
|
dependency_overrides_provider = config.dependency_overrides_provider |
||||
|
embed_body_fields = config.embed_body_fields |
||||
|
strict_content_type = config.strict_content_type |
||||
|
stream_item_field = config.stream_item_field |
||||
|
is_json_stream = config.is_json_stream |
||||
|
|
||||
|
assert dependant.call is not None, "dependant.call must be a function" |
||||
|
is_coroutine = dependant.is_coroutine_callable |
||||
|
is_body_form = body_field and isinstance(body_field.field_info, params.Form) |
||||
|
if isinstance(response_class, DefaultPlaceholder): |
||||
|
actual_response_class: type[Response] = response_class.value |
||||
|
else: |
||||
|
actual_response_class = response_class |
||||
|
is_sse_stream = lenient_issubclass(actual_response_class, EventSourceResponse) |
||||
|
if isinstance(strict_content_type, DefaultPlaceholder): |
||||
|
actual_strict_content_type: bool = strict_content_type.value |
||||
|
else: |
||||
|
actual_strict_content_type = strict_content_type |
||||
|
|
||||
|
async def app(request: Request) -> Response: |
||||
|
response: Response | None = None |
||||
|
file_stack = request.scope.get("fastapi_middleware_astack") |
||||
|
assert isinstance(file_stack, AsyncExitStack), ( |
||||
|
"fastapi_middleware_astack not found in request scope" |
||||
|
) |
||||
|
|
||||
|
endpoint_ctx = ( |
||||
|
extract_endpoint_context(dependant.call) |
||||
|
if dependant.call |
||||
|
else EndpointContext() |
||||
|
) |
||||
|
|
||||
|
if dependant.path: |
||||
|
mount_path = request.scope.get("root_path", "").rstrip("/") |
||||
|
endpoint_ctx["path"] = f"{request.method} {mount_path}{dependant.path}" |
||||
|
|
||||
|
try: |
||||
|
body: Any = None |
||||
|
if body_field: |
||||
|
if is_body_form: |
||||
|
body = await request.form() |
||||
|
file_stack.push_async_callback(body.close) |
||||
|
else: |
||||
|
body_bytes = await request.body() |
||||
|
if body_bytes: |
||||
|
json_body: Any = Undefined |
||||
|
content_type_value = request.headers.get("content-type") |
||||
|
if not content_type_value: |
||||
|
if not actual_strict_content_type: |
||||
|
json_body = await request.json() |
||||
|
else: |
||||
|
message = email.message.Message() |
||||
|
message["content-type"] = content_type_value |
||||
|
if message.get_content_maintype() == "application": |
||||
|
subtype = message.get_content_subtype() |
||||
|
if subtype == "json" or subtype.endswith("+json"): |
||||
|
json_body = await request.json() |
||||
|
if json_body != Undefined: |
||||
|
body = json_body |
||||
|
else: |
||||
|
body = body_bytes |
||||
|
except json.JSONDecodeError as e: |
||||
|
validation_error = RequestValidationError( |
||||
|
[ |
||||
|
{ |
||||
|
"type": "json_invalid", |
||||
|
"loc": ("body", e.pos), |
||||
|
"msg": "JSON decode error", |
||||
|
"input": {}, |
||||
|
"ctx": {"error": e.msg}, |
||||
|
} |
||||
|
], |
||||
|
body=e.doc, |
||||
|
endpoint_ctx=endpoint_ctx, |
||||
|
) |
||||
|
raise validation_error from e |
||||
|
except HTTPException: |
||||
|
raise |
||||
|
except Exception as e: |
||||
|
http_error = HTTPException( |
||||
|
status_code=400, detail="There was an error parsing the body" |
||||
|
) |
||||
|
raise http_error from e |
||||
|
|
||||
|
async_exit_stack = request.scope.get("fastapi_inner_astack") |
||||
|
assert isinstance(async_exit_stack, AsyncExitStack), ( |
||||
|
"fastapi_inner_astack not found in request scope" |
||||
|
) |
||||
|
solved_result = await solve_dependencies( |
||||
|
request=request, |
||||
|
dependant=dependant, |
||||
|
body=cast(dict[str, Any] | FormData | bytes | None, body), |
||||
|
dependency_overrides_provider=dependency_overrides_provider, |
||||
|
async_exit_stack=async_exit_stack, |
||||
|
embed_body_fields=embed_body_fields, |
||||
|
) |
||||
|
errors = solved_result.errors |
||||
|
assert dependant.call |
||||
|
if not errors: |
||||
|
def _serialize_data(data: Any) -> bytes: |
||||
|
if stream_item_field: |
||||
|
value, errors_ = stream_item_field.validate(data, {}, loc=("response",)) |
||||
|
if errors_: |
||||
|
ctx = endpoint_ctx or EndpointContext() |
||||
|
raise ResponseValidationError( |
||||
|
errors=errors_, |
||||
|
body=data, |
||||
|
endpoint_ctx=ctx, |
||||
|
) |
||||
|
return stream_item_field.serialize_json( |
||||
|
value, |
||||
|
include=response_model_include, |
||||
|
exclude=response_model_exclude, |
||||
|
by_alias=response_model_by_alias, |
||||
|
exclude_unset=response_model_exclude_unset, |
||||
|
exclude_defaults=response_model_exclude_defaults, |
||||
|
exclude_none=response_model_exclude_none, |
||||
|
) |
||||
|
data = jsonable_encoder(data) |
||||
|
return json.dumps(data).encode("utf-8") |
||||
|
|
||||
|
if is_sse_stream: |
||||
|
gen = dependant.call(**solved_result.values) |
||||
|
|
||||
|
def _serialize_sse_item(item: Any) -> bytes: |
||||
|
if isinstance(item, ServerSentEvent): |
||||
|
if item.raw_data is not None: |
||||
|
data_str: str | None = item.raw_data |
||||
|
elif item.data is not None: |
||||
|
if hasattr(item.data, "model_dump_json"): |
||||
|
data_str = item.data.model_dump_json() |
||||
|
else: |
||||
|
data_str = json.dumps(jsonable_encoder(item.data)) |
||||
|
else: |
||||
|
data_str = None |
||||
|
return format_sse_event( |
||||
|
data_str=data_str, |
||||
|
event=item.event, |
||||
|
id=item.id, |
||||
|
retry=item.retry, |
||||
|
comment=item.comment, |
||||
|
) |
||||
|
return format_sse_event(data_str=_serialize_data(item).decode("utf-8")) |
||||
|
|
||||
|
if dependant.is_async_gen_callable: |
||||
|
sse_aiter: AsyncIterator[Any] = gen.__aiter__() |
||||
|
else: |
||||
|
sse_aiter = iterate_in_threadpool(gen) |
||||
|
|
||||
|
@asynccontextmanager |
||||
|
async def _sse_producer_cm() -> AsyncIterator[ObjectReceiveStream[bytes]]: |
||||
|
send_stream, receive_stream = anyio.create_memory_object_stream[bytes]( |
||||
|
max_buffer_size=1 |
||||
|
) |
||||
|
|
||||
|
async def _producer() -> None: |
||||
|
async with send_stream: |
||||
|
async for raw_item in sse_aiter: |
||||
|
await send_stream.send(_serialize_sse_item(raw_item)) |
||||
|
|
||||
|
send_keepalive, receive_keepalive = anyio.create_memory_object_stream[ |
||||
|
bytes |
||||
|
](max_buffer_size=1) |
||||
|
|
||||
|
async def _keepalive_inserter() -> None: |
||||
|
async with send_keepalive, receive_stream: |
||||
|
try: |
||||
|
while True: |
||||
|
try: |
||||
|
with anyio.fail_after(_get_ping_interval()): |
||||
|
data = await receive_stream.receive() |
||||
|
await send_keepalive.send(data) |
||||
|
except TimeoutError: |
||||
|
await send_keepalive.send(KEEPALIVE_COMMENT) |
||||
|
except anyio.EndOfStream: |
||||
|
pass |
||||
|
|
||||
|
async with anyio.create_task_group() as tg: |
||||
|
tg.start_soon(_producer) |
||||
|
tg.start_soon(_keepalive_inserter) |
||||
|
yield receive_keepalive |
||||
|
tg.cancel_scope.cancel() |
||||
|
|
||||
|
sse_receive_stream = await async_exit_stack.enter_async_context( |
||||
|
_sse_producer_cm() |
||||
|
) |
||||
|
async_exit_stack.push_async_callback(sse_receive_stream.aclose) |
||||
|
|
||||
|
async def _sse_with_checkpoints( |
||||
|
stream: ObjectReceiveStream[bytes], |
||||
|
) -> AsyncIterator[bytes]: |
||||
|
async for data in stream: |
||||
|
yield data |
||||
|
await anyio.sleep(0) |
||||
|
|
||||
|
sse_stream_content: AsyncIterator[bytes] | Iterator[bytes] = ( |
||||
|
_sse_with_checkpoints(sse_receive_stream) |
||||
|
) |
||||
|
|
||||
|
response = StreamingResponse( |
||||
|
sse_stream_content, |
||||
|
media_type="text/event-stream", |
||||
|
background=solved_result.background_tasks, |
||||
|
) |
||||
|
response.headers["Cache-Control"] = "no-cache" |
||||
|
response.headers["X-Accel-Buffering"] = "no" |
||||
|
response.headers.raw.extend(solved_result.response.headers.raw) |
||||
|
elif is_json_stream: |
||||
|
gen = dependant.call(**solved_result.values) |
||||
|
|
||||
|
def _serialize_item(item: Any) -> bytes: |
||||
|
return _serialize_data(item) + b"\n" |
||||
|
|
||||
|
if dependant.is_async_gen_callable: |
||||
|
|
||||
|
async def _async_stream_jsonl() -> AsyncIterator[bytes]: |
||||
|
async for item in gen: |
||||
|
yield _serialize_item(item) |
||||
|
await anyio.sleep(0) |
||||
|
|
||||
|
jsonl_stream_content: AsyncIterator[bytes] | Iterator[bytes] = ( |
||||
|
_async_stream_jsonl() |
||||
|
) |
||||
|
else: |
||||
|
|
||||
|
def _sync_stream_jsonl() -> Iterator[bytes]: |
||||
|
for item in gen: # ty: ignore[not-iterable] |
||||
|
yield _serialize_item(item) |
||||
|
|
||||
|
jsonl_stream_content = _sync_stream_jsonl() |
||||
|
|
||||
|
response = StreamingResponse( |
||||
|
jsonl_stream_content, |
||||
|
media_type="application/jsonl", |
||||
|
background=solved_result.background_tasks, |
||||
|
) |
||||
|
response.headers.raw.extend(solved_result.response.headers.raw) |
||||
|
elif dependant.is_async_gen_callable or dependant.is_gen_callable: |
||||
|
gen = dependant.call(**solved_result.values) |
||||
|
if dependant.is_async_gen_callable: |
||||
|
|
||||
|
async def _async_stream_raw( |
||||
|
async_gen: AsyncIterator[Any], |
||||
|
) -> AsyncIterator[Any]: |
||||
|
async for chunk in async_gen: |
||||
|
yield chunk |
||||
|
await anyio.sleep(0) |
||||
|
|
||||
|
gen = _async_stream_raw(gen) |
||||
|
response_args = build_response_args( |
||||
|
status_code=status_code, solved_result=solved_result |
||||
|
) |
||||
|
response = actual_response_class(content=gen, **response_args) |
||||
|
response.headers.raw.extend(solved_result.response.headers.raw) |
||||
|
else: |
||||
|
raw_response = await run_endpoint_function( |
||||
|
dependant=dependant, |
||||
|
values=solved_result.values, |
||||
|
is_coroutine=is_coroutine, |
||||
|
) |
||||
|
if isinstance(raw_response, Response): |
||||
|
if raw_response.background is None: |
||||
|
raw_response.background = solved_result.background_tasks |
||||
|
response = raw_response |
||||
|
else: |
||||
|
response_args = build_response_args( |
||||
|
status_code=status_code, solved_result=solved_result |
||||
|
) |
||||
|
use_dump_json = response_field is not None and isinstance( |
||||
|
response_class, DefaultPlaceholder |
||||
|
) |
||||
|
content = await serialize_response( |
||||
|
field=response_field, |
||||
|
response_content=raw_response, |
||||
|
include=response_model_include, |
||||
|
exclude=response_model_exclude, |
||||
|
by_alias=response_model_by_alias, |
||||
|
exclude_unset=response_model_exclude_unset, |
||||
|
exclude_defaults=response_model_exclude_defaults, |
||||
|
exclude_none=response_model_exclude_none, |
||||
|
is_coroutine=is_coroutine, |
||||
|
endpoint_ctx=endpoint_ctx, |
||||
|
dump_json=use_dump_json, |
||||
|
) |
||||
|
if use_dump_json: |
||||
|
response = Response( |
||||
|
content=content, |
||||
|
media_type="application/json", |
||||
|
**response_args, |
||||
|
) |
||||
|
else: |
||||
|
response = actual_response_class(content, **response_args) |
||||
|
if not is_body_allowed_for_status_code(response.status_code): |
||||
|
response.body = b"" |
||||
|
response.headers.raw.extend(solved_result.response.headers.raw) |
||||
|
if errors: |
||||
|
raise RequestValidationError(errors, body=body, endpoint_ctx=endpoint_ctx) |
||||
|
|
||||
|
assert response |
||||
|
return response |
||||
|
|
||||
|
return app |
||||
|
|
||||
|
|
||||
|
def get_websocket_app( |
||||
|
dependant: Dependant, |
||||
|
dependency_overrides_provider: Any | None = None, |
||||
|
embed_body_fields: bool = False, |
||||
|
) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]: |
||||
|
async def app(websocket: WebSocket) -> None: |
||||
|
endpoint_ctx = ( |
||||
|
extract_endpoint_context(dependant.call) |
||||
|
if dependant.call |
||||
|
else EndpointContext() |
||||
|
) |
||||
|
if dependant.path: |
||||
|
mount_path = websocket.scope.get("root_path", "").rstrip("/") |
||||
|
endpoint_ctx["path"] = f"WS {mount_path}{dependant.path}" |
||||
|
async_exit_stack = websocket.scope.get("fastapi_inner_astack") |
||||
|
assert isinstance(async_exit_stack, AsyncExitStack), ( |
||||
|
"fastapi_inner_astack not found in request scope" |
||||
|
) |
||||
|
solved_result = await solve_dependencies( |
||||
|
request=websocket, |
||||
|
dependant=dependant, |
||||
|
dependency_overrides_provider=dependency_overrides_provider, |
||||
|
async_exit_stack=async_exit_stack, |
||||
|
embed_body_fields=embed_body_fields, |
||||
|
) |
||||
|
if solved_result.errors: |
||||
|
raise WebSocketRequestValidationError( |
||||
|
solved_result.errors, |
||||
|
endpoint_ctx=endpoint_ctx, |
||||
|
) |
||||
|
assert dependant.call is not None, "dependant.call must be a function" |
||||
|
await dependant.call(**solved_result.values) |
||||
|
|
||||
|
return app |
||||
File diff suppressed because it is too large
@ -0,0 +1,259 @@ |
|||||
|
import inspect |
||||
|
from collections.abc import Callable, Coroutine, Sequence |
||||
|
from enum import Enum, IntEnum |
||||
|
from typing import Any |
||||
|
|
||||
|
from fastapi import params |
||||
|
from fastapi._compat import ModelField, lenient_issubclass |
||||
|
from fastapi.datastructures import Default, DefaultPlaceholder |
||||
|
from fastapi.dependencies.utils import ( |
||||
|
_should_embed_body_fields, |
||||
|
get_body_field, |
||||
|
get_dependant, |
||||
|
get_flat_dependant, |
||||
|
get_parameterless_sub_dependant, |
||||
|
get_stream_item_type, |
||||
|
get_typed_return_annotation, |
||||
|
) |
||||
|
from fastapi.routing_handlers import ( |
||||
|
RouteHandlerConfig, |
||||
|
get_request_handler, |
||||
|
get_websocket_app, |
||||
|
) |
||||
|
from fastapi.routing_utils import request_response, websocket_session |
||||
|
from fastapi.sse import EventSourceResponse, ServerSentEvent |
||||
|
from fastapi.types import IncEx |
||||
|
from fastapi.utils import create_model_field, generate_unique_id, is_body_allowed_for_status_code |
||||
|
from starlette import routing |
||||
|
from starlette.requests import Request |
||||
|
from starlette.responses import JSONResponse, Response |
||||
|
from starlette.routing import BaseRoute, Match, compile_path, get_name |
||||
|
from starlette.types import Scope |
||||
|
|
||||
|
|
||||
|
class APIWebSocketRoute(routing.WebSocketRoute): |
||||
|
def __init__( |
||||
|
self, |
||||
|
path: str, |
||||
|
endpoint: Callable[..., Any], |
||||
|
*, |
||||
|
name: str | None = None, |
||||
|
dependencies: Sequence[params.Depends] | None = None, |
||||
|
dependency_overrides_provider: Any | None = None, |
||||
|
) -> None: |
||||
|
self.path = path |
||||
|
self.endpoint = endpoint |
||||
|
self.name = get_name(endpoint) if name is None else name |
||||
|
self.dependencies = list(dependencies or []) |
||||
|
self.path_regex, self.path_format, self.param_convertors = compile_path(path) |
||||
|
self.dependant = get_dependant( |
||||
|
path=self.path_format, call=self.endpoint, scope="function" |
||||
|
) |
||||
|
for depends in self.dependencies[::-1]: |
||||
|
self.dependant.dependencies.insert( |
||||
|
0, |
||||
|
get_parameterless_sub_dependant(depends=depends, path=self.path_format), |
||||
|
) |
||||
|
self._flat_dependant = get_flat_dependant(self.dependant) |
||||
|
self._embed_body_fields = _should_embed_body_fields( |
||||
|
self._flat_dependant.body_params |
||||
|
) |
||||
|
self.app = websocket_session( |
||||
|
get_websocket_app( |
||||
|
dependant=self.dependant, |
||||
|
dependency_overrides_provider=dependency_overrides_provider, |
||||
|
embed_body_fields=self._embed_body_fields, |
||||
|
) |
||||
|
) |
||||
|
|
||||
|
def matches(self, scope: Scope) -> tuple[Match, Scope]: |
||||
|
match, child_scope = super().matches(scope) |
||||
|
if match != Match.NONE: |
||||
|
child_scope["route"] = self |
||||
|
return match, child_scope |
||||
|
|
||||
|
|
||||
|
class APIRoute(routing.Route): |
||||
|
def __init__( |
||||
|
self, |
||||
|
path: str, |
||||
|
endpoint: Callable[..., Any], |
||||
|
*, |
||||
|
response_model: Any = Default(None), |
||||
|
status_code: int | None = None, |
||||
|
tags: list[str | Enum] | None = None, |
||||
|
dependencies: Sequence[params.Depends] | None = None, |
||||
|
summary: str | None = None, |
||||
|
description: str | None = None, |
||||
|
response_description: str = "Successful Response", |
||||
|
responses: dict[int | str, dict[str, Any]] | None = None, |
||||
|
deprecated: bool | None = None, |
||||
|
name: str | None = None, |
||||
|
methods: set[str] | list[str] | None = None, |
||||
|
operation_id: str | None = None, |
||||
|
response_model_include: IncEx | None = None, |
||||
|
response_model_exclude: IncEx | None = None, |
||||
|
response_model_by_alias: bool = True, |
||||
|
response_model_exclude_unset: bool = False, |
||||
|
response_model_exclude_defaults: bool = False, |
||||
|
response_model_exclude_none: bool = False, |
||||
|
include_in_schema: bool = True, |
||||
|
response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), |
||||
|
dependency_overrides_provider: Any | None = None, |
||||
|
callbacks: list[BaseRoute] | None = None, |
||||
|
openapi_extra: dict[str, Any] | None = None, |
||||
|
generate_unique_id_function: Callable[["APIRoute"], str] |
||||
|
| DefaultPlaceholder = Default(generate_unique_id), |
||||
|
strict_content_type: bool | DefaultPlaceholder = Default(True), |
||||
|
) -> None: |
||||
|
self.path = path |
||||
|
self.endpoint = endpoint |
||||
|
self.stream_item_type: Any | None = None |
||||
|
if isinstance(response_model, DefaultPlaceholder): |
||||
|
return_annotation = get_typed_return_annotation(endpoint) |
||||
|
if lenient_issubclass(return_annotation, Response): |
||||
|
response_model = None |
||||
|
else: |
||||
|
stream_item = get_stream_item_type(return_annotation) |
||||
|
if stream_item is not None: |
||||
|
if ( |
||||
|
isinstance(response_class, DefaultPlaceholder) |
||||
|
or lenient_issubclass(response_class, EventSourceResponse) |
||||
|
) and not lenient_issubclass(stream_item, ServerSentEvent): |
||||
|
self.stream_item_type = stream_item |
||||
|
response_model = None |
||||
|
else: |
||||
|
response_model = return_annotation |
||||
|
self.response_model = response_model |
||||
|
self.summary = summary |
||||
|
self.response_description = response_description |
||||
|
self.deprecated = deprecated |
||||
|
self.operation_id = operation_id |
||||
|
self.response_model_include = response_model_include |
||||
|
self.response_model_exclude = response_model_exclude |
||||
|
self.response_model_by_alias = response_model_by_alias |
||||
|
self.response_model_exclude_unset = response_model_exclude_unset |
||||
|
self.response_model_exclude_defaults = response_model_exclude_defaults |
||||
|
self.response_model_exclude_none = response_model_exclude_none |
||||
|
self.include_in_schema = include_in_schema |
||||
|
self.response_class = response_class |
||||
|
self.dependency_overrides_provider = dependency_overrides_provider |
||||
|
self.callbacks = callbacks |
||||
|
self.openapi_extra = openapi_extra |
||||
|
self.generate_unique_id_function = generate_unique_id_function |
||||
|
self.strict_content_type = strict_content_type |
||||
|
self.tags = tags or [] |
||||
|
self.responses = responses or {} |
||||
|
self.name = get_name(endpoint) if name is None else name |
||||
|
self.path_regex, self.path_format, self.param_convertors = compile_path(path) |
||||
|
if methods is None: |
||||
|
methods = ["GET"] |
||||
|
self.methods: set[str] = {method.upper() for method in methods} |
||||
|
if isinstance(generate_unique_id_function, DefaultPlaceholder): |
||||
|
current_generate_unique_id: Callable[[APIRoute], str] = ( |
||||
|
generate_unique_id_function.value |
||||
|
) |
||||
|
else: |
||||
|
current_generate_unique_id = generate_unique_id_function |
||||
|
self.unique_id = self.operation_id or current_generate_unique_id(self) |
||||
|
if isinstance(status_code, IntEnum): |
||||
|
status_code = int(status_code) |
||||
|
self.status_code = status_code |
||||
|
if self.response_model: |
||||
|
assert is_body_allowed_for_status_code(status_code), ( |
||||
|
f"Status code {status_code} must not have a response body" |
||||
|
) |
||||
|
response_name = "Response_" + self.unique_id |
||||
|
self.response_field = create_model_field( |
||||
|
name=response_name, |
||||
|
type_=self.response_model, |
||||
|
mode="serialization", |
||||
|
) |
||||
|
else: |
||||
|
self.response_field = None # type: ignore |
||||
|
if self.stream_item_type: |
||||
|
stream_item_name = "StreamItem_" + self.unique_id |
||||
|
self.stream_item_field: ModelField | None = create_model_field( |
||||
|
name=stream_item_name, |
||||
|
type_=self.stream_item_type, |
||||
|
mode="serialization", |
||||
|
) |
||||
|
else: |
||||
|
self.stream_item_field = None |
||||
|
self.dependencies = list(dependencies or []) |
||||
|
self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "") |
||||
|
self.description = self.description.split("\f")[0].strip() |
||||
|
response_fields = {} |
||||
|
for additional_status_code, response in self.responses.items(): |
||||
|
assert isinstance(response, dict), "An additional response must be a dict" |
||||
|
model = response.get("model") |
||||
|
if model: |
||||
|
assert is_body_allowed_for_status_code(additional_status_code), ( |
||||
|
f"Status code {additional_status_code} must not have a response body" |
||||
|
) |
||||
|
response_name = f"Response_{additional_status_code}_{self.unique_id}" |
||||
|
response_field = create_model_field( |
||||
|
name=response_name, type_=model, mode="serialization" |
||||
|
) |
||||
|
response_fields[additional_status_code] = response_field |
||||
|
if response_fields: |
||||
|
self.response_fields: dict[int | str, ModelField] = response_fields |
||||
|
else: |
||||
|
self.response_fields = {} |
||||
|
|
||||
|
assert callable(endpoint), "An endpoint must be a callable" |
||||
|
self.dependant = get_dependant( |
||||
|
path=self.path_format, call=self.endpoint, scope="function" |
||||
|
) |
||||
|
for depends in self.dependencies[::-1]: |
||||
|
self.dependant.dependencies.insert( |
||||
|
0, |
||||
|
get_parameterless_sub_dependant(depends=depends, path=self.path_format), |
||||
|
) |
||||
|
self._flat_dependant = get_flat_dependant(self.dependant) |
||||
|
self._embed_body_fields = _should_embed_body_fields( |
||||
|
self._flat_dependant.body_params |
||||
|
) |
||||
|
self.body_field = get_body_field( |
||||
|
flat_dependant=self._flat_dependant, |
||||
|
name=self.unique_id, |
||||
|
embed_body_fields=self._embed_body_fields, |
||||
|
) |
||||
|
is_generator = ( |
||||
|
self.dependant.is_async_gen_callable or self.dependant.is_gen_callable |
||||
|
) |
||||
|
self.is_sse_stream = is_generator and lenient_issubclass( |
||||
|
response_class, EventSourceResponse |
||||
|
) |
||||
|
self.is_json_stream = is_generator and isinstance( |
||||
|
response_class, DefaultPlaceholder |
||||
|
) |
||||
|
self.app = request_response(self.get_route_handler()) |
||||
|
|
||||
|
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: |
||||
|
return get_request_handler( |
||||
|
RouteHandlerConfig( |
||||
|
dependant=self.dependant, |
||||
|
body_field=self.body_field, |
||||
|
status_code=self.status_code, |
||||
|
response_class=self.response_class, |
||||
|
response_field=self.response_field, |
||||
|
response_model_include=self.response_model_include, |
||||
|
response_model_exclude=self.response_model_exclude, |
||||
|
response_model_by_alias=self.response_model_by_alias, |
||||
|
response_model_exclude_unset=self.response_model_exclude_unset, |
||||
|
response_model_exclude_defaults=self.response_model_exclude_defaults, |
||||
|
response_model_exclude_none=self.response_model_exclude_none, |
||||
|
dependency_overrides_provider=self.dependency_overrides_provider, |
||||
|
embed_body_fields=self._embed_body_fields, |
||||
|
strict_content_type=self.strict_content_type, |
||||
|
stream_item_field=self.stream_item_field, |
||||
|
is_json_stream=self.is_json_stream, |
||||
|
) |
||||
|
) |
||||
|
|
||||
|
def matches(self, scope: Scope) -> tuple[Match, Scope]: |
||||
|
match, child_scope = super().matches(scope) |
||||
|
if match != Match.NONE: |
||||
|
child_scope["route"] = self |
||||
|
return match, child_scope |
||||
@ -0,0 +1,231 @@ |
|||||
|
import contextlib |
||||
|
import functools |
||||
|
import inspect |
||||
|
import types |
||||
|
from collections.abc import ( |
||||
|
AsyncIterator, |
||||
|
Awaitable, |
||||
|
Callable, |
||||
|
Coroutine, |
||||
|
Generator, |
||||
|
Mapping, |
||||
|
) |
||||
|
from contextlib import ( |
||||
|
AbstractAsyncContextManager, |
||||
|
AbstractContextManager, |
||||
|
AsyncExitStack, |
||||
|
asynccontextmanager, |
||||
|
) |
||||
|
from typing import Any, TypeVar |
||||
|
|
||||
|
from fastapi._compat import ModelField |
||||
|
from fastapi.dependencies.models import Dependant |
||||
|
from fastapi.encoders import jsonable_encoder |
||||
|
from fastapi.exceptions import ( |
||||
|
EndpointContext, |
||||
|
FastAPIError, |
||||
|
ResponseValidationError, |
||||
|
) |
||||
|
from fastapi.types import IncEx |
||||
|
from starlette._exception_handler import wrap_app_handling_exceptions |
||||
|
from starlette._utils import is_async_callable |
||||
|
from starlette.concurrency import run_in_threadpool |
||||
|
from starlette.requests import Request |
||||
|
from starlette.responses import Response |
||||
|
from starlette.types import ASGIApp, AppType, Lifespan, Receive, Scope, Send |
||||
|
from starlette.websockets import WebSocket |
||||
|
|
||||
|
_T = TypeVar("_T") |
||||
|
|
||||
|
|
||||
|
def request_response( |
||||
|
func: Callable[[Request], Awaitable[Response] | Response], |
||||
|
) -> ASGIApp: |
||||
|
f: Callable[[Request], Awaitable[Response]] = ( |
||||
|
func |
||||
|
if is_async_callable(func) |
||||
|
else functools.partial(run_in_threadpool, func) # type: ignore[call-arg] |
||||
|
) |
||||
|
|
||||
|
async def app(scope: Scope, receive: Receive, send: Send) -> None: |
||||
|
request = Request(scope, receive, send) |
||||
|
|
||||
|
async def app(scope: Scope, receive: Receive, send: Send) -> None: |
||||
|
response_awaited = False |
||||
|
async with AsyncExitStack() as request_stack: |
||||
|
scope["fastapi_inner_astack"] = request_stack |
||||
|
async with AsyncExitStack() as function_stack: |
||||
|
scope["fastapi_function_astack"] = function_stack |
||||
|
response = await f(request) |
||||
|
await response(scope, receive, send) |
||||
|
response_awaited = True |
||||
|
if not response_awaited: |
||||
|
raise FastAPIError( |
||||
|
"Response not awaited. There's a high chance that the " |
||||
|
"application code is raising an exception and a dependency with yield " |
||||
|
"has a block with a bare except, or a block with except Exception, " |
||||
|
"and is not raising the exception again. Read more about it in the " |
||||
|
"docs: https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/#dependencies-with-yield-and-except" |
||||
|
) |
||||
|
|
||||
|
await wrap_app_handling_exceptions(app, request)(scope, receive, send) |
||||
|
|
||||
|
return app |
||||
|
|
||||
|
|
||||
|
def websocket_session( |
||||
|
func: Callable[[WebSocket], Awaitable[None]], |
||||
|
) -> ASGIApp: |
||||
|
async def app(scope: Scope, receive: Receive, send: Send) -> None: |
||||
|
session = WebSocket(scope, receive=receive, send=send) |
||||
|
|
||||
|
async def app(scope: Scope, receive: Receive, send: Send) -> None: |
||||
|
async with AsyncExitStack() as request_stack: |
||||
|
scope["fastapi_inner_astack"] = request_stack |
||||
|
async with AsyncExitStack() as function_stack: |
||||
|
scope["fastapi_function_astack"] = function_stack |
||||
|
await func(session) |
||||
|
|
||||
|
await wrap_app_handling_exceptions(app, session)(scope, receive, send) |
||||
|
|
||||
|
return app |
||||
|
|
||||
|
|
||||
|
class _AsyncLiftContextManager(AbstractAsyncContextManager[_T]): |
||||
|
def __init__(self, cm: AbstractContextManager[_T]) -> None: |
||||
|
self._cm = cm |
||||
|
|
||||
|
async def __aenter__(self) -> _T: |
||||
|
return self._cm.__enter__() |
||||
|
|
||||
|
async def __aexit__( |
||||
|
self, |
||||
|
exc_type: type[BaseException] | None, |
||||
|
exc_value: BaseException | None, |
||||
|
traceback: types.TracebackType | None, |
||||
|
) -> bool | None: |
||||
|
return self._cm.__exit__(exc_type, exc_value, traceback) |
||||
|
|
||||
|
|
||||
|
def _wrap_gen_lifespan_context( |
||||
|
lifespan_context: Callable[[Any], Generator[Any, Any, Any]], |
||||
|
) -> Callable[[Any], AbstractAsyncContextManager[Any]]: |
||||
|
cmgr = contextlib.contextmanager(lifespan_context) |
||||
|
|
||||
|
@functools.wraps(cmgr) |
||||
|
def wrapper(app: Any) -> _AsyncLiftContextManager[Any]: |
||||
|
return _AsyncLiftContextManager(cmgr(app)) |
||||
|
|
||||
|
return wrapper |
||||
|
|
||||
|
|
||||
|
def _merge_lifespan_context( |
||||
|
original_context: Lifespan[Any], nested_context: Lifespan[Any] |
||||
|
) -> Lifespan[Any]: |
||||
|
@asynccontextmanager |
||||
|
async def merged_lifespan( |
||||
|
app: AppType, |
||||
|
) -> AsyncIterator[Mapping[str, Any] | None]: |
||||
|
async with original_context(app) as maybe_original_state: |
||||
|
async with nested_context(app) as maybe_nested_state: |
||||
|
if maybe_nested_state is None and maybe_original_state is None: |
||||
|
yield None |
||||
|
else: |
||||
|
yield {**(maybe_nested_state or {}), **(maybe_original_state or {})} |
||||
|
|
||||
|
return merged_lifespan # type: ignore[return-value] |
||||
|
|
||||
|
|
||||
|
class _DefaultLifespan: |
||||
|
def __init__(self, router: Any) -> None: |
||||
|
self._router = router |
||||
|
|
||||
|
async def __aenter__(self) -> None: |
||||
|
await self._router._startup() |
||||
|
|
||||
|
async def __aexit__(self, *exc_info: object) -> None: |
||||
|
await self._router._shutdown() |
||||
|
|
||||
|
def __call__(self: _T, app: object) -> _T: |
||||
|
return self |
||||
|
|
||||
|
|
||||
|
_endpoint_context_cache: dict[int, EndpointContext] = {} |
||||
|
|
||||
|
|
||||
|
def extract_endpoint_context(func: Any) -> EndpointContext: |
||||
|
func_id = id(func) |
||||
|
if func_id in _endpoint_context_cache: |
||||
|
return _endpoint_context_cache[func_id] |
||||
|
try: |
||||
|
ctx: EndpointContext = {} |
||||
|
if (source_file := inspect.getsourcefile(func)) is not None: |
||||
|
ctx["file"] = source_file |
||||
|
if (line_number := inspect.getsourcelines(func)[1]) is not None: |
||||
|
ctx["line"] = line_number |
||||
|
if (func_name := getattr(func, "__name__", None)) is not None: |
||||
|
ctx["function"] = func_name |
||||
|
except Exception: |
||||
|
ctx = EndpointContext() |
||||
|
_endpoint_context_cache[func_id] = ctx |
||||
|
return ctx |
||||
|
|
||||
|
|
||||
|
async def serialize_response( |
||||
|
*, |
||||
|
field: ModelField | None = None, |
||||
|
response_content: Any, |
||||
|
include: IncEx | None = None, |
||||
|
exclude: IncEx | None = None, |
||||
|
by_alias: bool = True, |
||||
|
exclude_unset: bool = False, |
||||
|
exclude_defaults: bool = False, |
||||
|
exclude_none: bool = False, |
||||
|
is_coroutine: bool = True, |
||||
|
endpoint_ctx: EndpointContext | None = None, |
||||
|
dump_json: bool = False, |
||||
|
) -> Any: |
||||
|
if field: |
||||
|
if is_coroutine: |
||||
|
value, errors = field.validate(response_content, {}, loc=("response",)) |
||||
|
else: |
||||
|
value, errors = await run_in_threadpool( |
||||
|
field.validate, response_content, {}, loc=("response",) |
||||
|
) |
||||
|
if errors: |
||||
|
ctx = endpoint_ctx or EndpointContext() |
||||
|
raise ResponseValidationError( |
||||
|
errors=errors, |
||||
|
body=response_content, |
||||
|
endpoint_ctx=ctx, |
||||
|
) |
||||
|
serializer = field.serialize_json if dump_json else field.serialize |
||||
|
return serializer( |
||||
|
value, |
||||
|
include=include, |
||||
|
exclude=exclude, |
||||
|
by_alias=by_alias, |
||||
|
exclude_unset=exclude_unset, |
||||
|
exclude_defaults=exclude_defaults, |
||||
|
exclude_none=exclude_none, |
||||
|
) |
||||
|
return jsonable_encoder(response_content) |
||||
|
|
||||
|
|
||||
|
async def run_endpoint_function( |
||||
|
*, dependant: Dependant, values: dict[str, Any], is_coroutine: bool |
||||
|
) -> Any: |
||||
|
assert dependant.call is not None, "dependant.call must be a function" |
||||
|
if is_coroutine: |
||||
|
return await dependant.call(**values) |
||||
|
return await run_in_threadpool(dependant.call, **values) |
||||
|
|
||||
|
|
||||
|
def build_response_args(*, status_code: int | None, solved_result: Any) -> dict[str, Any]: |
||||
|
response_args: dict[str, Any] = {"background": solved_result.background_tasks} |
||||
|
current_status_code = status_code if status_code else solved_result.response.status_code |
||||
|
if current_status_code is not None: |
||||
|
response_args["status_code"] = current_status_code |
||||
|
if solved_result.response.status_code: |
||||
|
response_args["status_code"] = solved_result.response.status_code |
||||
|
return response_args |
||||
@ -0,0 +1,37 @@ |
|||||
|
import fastapi.routing as routing |
||||
|
|
||||
|
from fastapi.routing_handlers import RouteHandlerConfig |
||||
|
from fastapi.routing_router import APIRouter |
||||
|
from fastapi.routing_routes import APIRoute, APIWebSocketRoute |
||||
|
from fastapi.routing_utils import request_response, websocket_session |
||||
|
|
||||
|
|
||||
|
def test_routing_facade_exports_expected_symbols() -> None: |
||||
|
expected = { |
||||
|
"APIRouter", |
||||
|
"APIRoute", |
||||
|
"APIWebSocketRoute", |
||||
|
"RouteHandlerConfig", |
||||
|
"get_request_handler", |
||||
|
"get_websocket_app", |
||||
|
"request_response", |
||||
|
"websocket_session", |
||||
|
"_PING_INTERVAL", |
||||
|
} |
||||
|
assert expected.issubset(set(routing.__all__)) |
||||
|
|
||||
|
|
||||
|
def test_routing_facade_re_exports_router_classes() -> None: |
||||
|
assert routing.APIRouter is APIRouter |
||||
|
assert routing.APIRoute is APIRoute |
||||
|
assert routing.APIWebSocketRoute is APIWebSocketRoute |
||||
|
|
||||
|
|
||||
|
def test_routing_facade_re_exports_handler_contracts() -> None: |
||||
|
assert routing.RouteHandlerConfig is RouteHandlerConfig |
||||
|
assert routing.request_response is request_response |
||||
|
assert routing.websocket_session is websocket_session |
||||
|
|
||||
|
|
||||
|
def test_routing_ping_constant_compatibility() -> None: |
||||
|
assert isinstance(routing._PING_INTERVAL, (float, int)) |
||||
Loading…
Reference in new issue