diff --git a/CHANGES.md b/CHANGES.md index 2bba899c6..64b595bac 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- add `enable_direct_response` settings to by-pass Pydantic validation and FastAPI serialization for responses + ## [5.1.1] - 2025-03-17 ### Fixed diff --git a/README.md b/README.md index 73fe11b93..19d5031bc 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,6 @@ To turn on response validation, set `ENABLE_RESPONSE_MODELS` to `True`. Either a With the introduction of Pydantic 2, the extra [time it takes to validate models became negatable](https://github.com/stac-utils/stac-fastapi/pull/625#issuecomment-2045824578). While `ENABLE_RESPONSE_MODELS` still defaults to `False` there should be no penalty for users to turn on this feature but users discretion is advised. - ## Installation ```bash diff --git a/docs/src/tips-and-tricks.md b/docs/src/tips-and-tricks.md index 6c8e25373..5014deddd 100644 --- a/docs/src/tips-and-tricks.md +++ b/docs/src/tips-and-tricks.md @@ -2,6 +2,14 @@ This page contains a few 'tips and tricks' for getting **stac-fastapi** working in various situations. +## Avoid FastAPI (slow) serialization + +When not using Pydantic validation for responses, FastAPI will still use a complex (slow) [serialization process](https://github.com/fastapi/fastapi/discussions/8165). + +Starting with stac-fastapi `5.2.0`, we've added `ENABLE_DIRECT_RESPONSE` option to by-pass the default FastAPI serialization by wrapping the endpoint responses into `starlette.Response` classes. + +Ref: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/347 + ## Application Middlewares By default the `StacApi` class will enable 3 Middlewares (`BrotliMiddleware`, `CORSMiddleware` and `ProxyHeaderMiddleware`). You may want to overwrite the defaults configuration by editing your backend's `app.py`: diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 0e0bd412b..263384f98 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -25,7 +25,12 @@ ItemUri, ) from stac_fastapi.api.openapi import update_openapi -from stac_fastapi.api.routes import Scope, add_route_dependencies, create_async_endpoint +from stac_fastapi.api.routes import ( + Scope, + add_direct_response, + add_route_dependencies, + create_async_endpoint, +) from stac_fastapi.types.config import ApiSettings, Settings from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient from stac_fastapi.types.extension import ApiExtension @@ -425,3 +430,6 @@ def __attrs_post_init__(self) -> None: # customize route dependencies for scopes, dependencies in self.route_dependencies: self.add_route_dependencies(scopes=scopes, dependencies=dependencies) + + if self.app.state.settings.enable_direct_response: + add_direct_response(self.app) diff --git a/stac_fastapi/api/stac_fastapi/api/routes.py b/stac_fastapi/api/stac_fastapi/api/routes.py index c159faccd..bf051e6ab 100644 --- a/stac_fastapi/api/stac_fastapi/api/routes.py +++ b/stac_fastapi/api/stac_fastapi/api/routes.py @@ -5,13 +5,15 @@ import inspect from typing import Any, Callable, Dict, List, Optional, Type, TypedDict, Union -from fastapi import Depends, params -from fastapi.dependencies.utils import get_parameterless_sub_dependant +from fastapi import Depends, FastAPI, params +from fastapi.datastructures import DefaultPlaceholder +from fastapi.dependencies.utils import get_dependant, get_parameterless_sub_dependant +from fastapi.routing import APIRoute from pydantic import BaseModel from starlette.concurrency import run_in_threadpool from starlette.requests import Request from starlette.responses import Response -from starlette.routing import BaseRoute, Match +from starlette.routing import BaseRoute, Match, request_response from starlette.status import HTTP_204_NO_CONTENT from stac_fastapi.api.models import APIRequest @@ -131,3 +133,33 @@ def add_route_dependencies( # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360 # https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678 route.dependencies.extend(dependencies) + + +def add_direct_response(app: FastAPI) -> None: + """ + Setup FastAPI application's endpoints to return Response Object directly, avoiding + Pydantic validation and FastAPI (slow) serialization. + + ref: https://gist.github.com/Zaczero/00f3a2679ebc0a25eb938ed82bc63553 + """ + + def wrap_endpoint(endpoint: Callable, cls: Type[Response]): + @functools.wraps(endpoint) + async def wrapper(*args, **kwargs): + content = await endpoint(*args, **kwargs) + return content if isinstance(content, Response) else cls(content) + + return wrapper + + for route in app.routes: + if not isinstance(route, APIRoute): + continue + + response_class = route.response_class + if isinstance(response_class, DefaultPlaceholder): + response_class = response_class.value + + if issubclass(response_class, Response): + route.endpoint = wrap_endpoint(route.endpoint, response_class) + route.dependant = get_dependant(path=route.path_format, call=route.endpoint) + route.app = request_response(route.get_route_handler()) diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py index 7c77a9ab9..44894c891 100644 --- a/stac_fastapi/api/tests/test_app.py +++ b/stac_fastapi/api/tests/test_app.py @@ -108,6 +108,31 @@ def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: assert item.status_code == 200, item.text +def test_client_response_by_pass(TestCoreClient, item_dict): + """Check with `enable_direct_response` option.""" + + class InValidResponseClient(TestCoreClient): + def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item: + item_dict.pop("bbox", None) + item_dict.pop("geometry", None) + return item_dict + + test_app = app.StacApi( + settings=ApiSettings( + enable_response_models=False, + enable_direct_response=True, + ), + client=InValidResponseClient(), + ) + + with TestClient(test_app.app) as client: + item = client.get("/collections/test/items/test") + + assert item.json() + assert item.status_code == 200 + assert item.headers["content-type"] == "application/geo+json" + + def test_client_openapi(TestCoreClient): """Test if response models are all documented with OpenAPI.""" diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index 773ff3646..75ec9c2ec 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -2,7 +2,9 @@ from typing import Optional +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self class ApiSettings(BaseSettings): @@ -27,14 +29,29 @@ class ApiSettings(BaseSettings): app_host: str = "0.0.0.0" app_port: int = 8000 reload: bool = True + + # Enable Pydantic validation for output Response enable_response_models: bool = False + # Enable direct `Response` from endpoint, skipping validation and serialization + enable_direct_response: bool = False + openapi_url: str = "/api" docs_url: str = "/api.html" root_path: str = "" model_config = SettingsConfigDict(env_file=".env", extra="allow") + @model_validator(mode="after") + def check_incompatible_options(self) -> Self: + """Check for incompatible options.""" + if self.enable_response_models and self.enable_direct_response: + raise ValueError( + "`enable_reponse_models` and `enable_direct_response` options are incompatible" # noqa: E501 + ) + + return self + class Settings: """Holds the global instance of settings.""" diff --git a/stac_fastapi/types/tests/test_config.py b/stac_fastapi/types/tests/test_config.py new file mode 100644 index 000000000..d5b9e917f --- /dev/null +++ b/stac_fastapi/types/tests/test_config.py @@ -0,0 +1,29 @@ +"""test config classes.""" + +import pytest +from pydantic import ValidationError + +from stac_fastapi.types.config import ApiSettings + + +def test_incompatible_options(): + """test incompatible output model options.""" + settings = ApiSettings( + enable_response_models=True, + enable_direct_response=False, + ) + assert settings.enable_response_models + assert not settings.enable_direct_response + + settings = ApiSettings( + enable_response_models=False, + enable_direct_response=True, + ) + assert not settings.enable_response_models + assert settings.enable_direct_response + + with pytest.raises(ValidationError): + ApiSettings( + enable_response_models=True, + enable_direct_response=True, + )