Repository: stac-utils/stac-fastapi
Pull request #817 (2025-04-11 14:34:47)

add enable_direct_response option
ref https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/347

cc @Zaczero @jonhealy1 


Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 2bba899..64b595b 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 73fe11b..19d5031 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 6c8e253..5014ded 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 0e0bd41..263384f 100644
--- a/stac_fastapi/api/stac_fastapi/api/app.py
+++ b/stac_fastapi/api/stac_fastapi/api/app.py
@@ -25,7 +25,12 @@ from stac_fastapi.api.models import (
     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 @@ class StacApi:
         # 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 c159fac..bf051e6 100644
--- a/stac_fastapi/api/stac_fastapi/api/routes.py
+++ b/stac_fastapi/api/stac_fastapi/api/routes.py
@@ -5,13 +5,15 @@ import functools
 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/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py
index 773ff36..75ec9c2 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."""


Test patch:
diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py
index 7c77a9a..44894c8 100644
--- a/stac_fastapi/api/tests/test_app.py
+++ b/stac_fastapi/api/tests/test_app.py
@@ -108,6 +108,31 @@ def test_client_invalid_response_type(validate, TestCoreClient, item_dict):
         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/tests/test_config.py b/stac_fastapi/types/tests/test_config.py
new file mode 100644
index 0000000..d5b9e91
--- /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,
+        )