Skip to content

Commit 68148c8

Browse files
add enable_direct_response option (#817)
1 parent ad39294 commit 68148c8

File tree

8 files changed

+127
-5
lines changed

8 files changed

+127
-5
lines changed

CHANGES.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- add `enable_direct_response` settings to by-pass Pydantic validation and FastAPI serialization for responses
8+
59
## [5.1.1] - 2025-03-17
610

711
### Fixed

README.md

-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ To turn on response validation, set `ENABLE_RESPONSE_MODELS` to `True`. Either a
5151

5252
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.
5353

54-
5554
## Installation
5655

5756
```bash

docs/src/tips-and-tricks.md

+8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
This page contains a few 'tips and tricks' for getting **stac-fastapi** working in various situations.
44

5+
## Avoid FastAPI (slow) serialization
6+
7+
When not using Pydantic validation for responses, FastAPI will still use a complex (slow) [serialization process](https://github.com/fastapi/fastapi/discussions/8165).
8+
9+
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.
10+
11+
Ref: https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/issues/347
12+
513
## Application Middlewares
614

715
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`:

stac_fastapi/api/stac_fastapi/api/app.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@
2525
ItemUri,
2626
)
2727
from stac_fastapi.api.openapi import update_openapi
28-
from stac_fastapi.api.routes import Scope, add_route_dependencies, create_async_endpoint
28+
from stac_fastapi.api.routes import (
29+
Scope,
30+
add_direct_response,
31+
add_route_dependencies,
32+
create_async_endpoint,
33+
)
2934
from stac_fastapi.types.config import ApiSettings, Settings
3035
from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient
3136
from stac_fastapi.types.extension import ApiExtension
@@ -425,3 +430,6 @@ def __attrs_post_init__(self) -> None:
425430
# customize route dependencies
426431
for scopes, dependencies in self.route_dependencies:
427432
self.add_route_dependencies(scopes=scopes, dependencies=dependencies)
433+
434+
if self.app.state.settings.enable_direct_response:
435+
add_direct_response(self.app)

stac_fastapi/api/stac_fastapi/api/routes.py

+35-3
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import inspect
66
from typing import Any, Callable, Dict, List, Optional, Type, TypedDict, Union
77

8-
from fastapi import Depends, params
9-
from fastapi.dependencies.utils import get_parameterless_sub_dependant
8+
from fastapi import Depends, FastAPI, params
9+
from fastapi.datastructures import DefaultPlaceholder
10+
from fastapi.dependencies.utils import get_dependant, get_parameterless_sub_dependant
11+
from fastapi.routing import APIRoute
1012
from pydantic import BaseModel
1113
from starlette.concurrency import run_in_threadpool
1214
from starlette.requests import Request
1315
from starlette.responses import Response
14-
from starlette.routing import BaseRoute, Match
16+
from starlette.routing import BaseRoute, Match, request_response
1517
from starlette.status import HTTP_204_NO_CONTENT
1618

1719
from stac_fastapi.api.models import APIRequest
@@ -131,3 +133,33 @@ def add_route_dependencies(
131133
# https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/applications.py#L337-L360
132134
# https://github.com/tiangolo/fastapi/blob/58ab733f19846b4875c5b79bfb1f4d1cb7f4823f/fastapi/routing.py#L677-L678
133135
route.dependencies.extend(dependencies)
136+
137+
138+
def add_direct_response(app: FastAPI) -> None:
139+
"""
140+
Setup FastAPI application's endpoints to return Response Object directly, avoiding
141+
Pydantic validation and FastAPI (slow) serialization.
142+
143+
ref: https://gist.github.com/Zaczero/00f3a2679ebc0a25eb938ed82bc63553
144+
"""
145+
146+
def wrap_endpoint(endpoint: Callable, cls: Type[Response]):
147+
@functools.wraps(endpoint)
148+
async def wrapper(*args, **kwargs):
149+
content = await endpoint(*args, **kwargs)
150+
return content if isinstance(content, Response) else cls(content)
151+
152+
return wrapper
153+
154+
for route in app.routes:
155+
if not isinstance(route, APIRoute):
156+
continue
157+
158+
response_class = route.response_class
159+
if isinstance(response_class, DefaultPlaceholder):
160+
response_class = response_class.value
161+
162+
if issubclass(response_class, Response):
163+
route.endpoint = wrap_endpoint(route.endpoint, response_class)
164+
route.dependant = get_dependant(path=route.path_format, call=route.endpoint)
165+
route.app = request_response(route.get_route_handler())

stac_fastapi/api/tests/test_app.py

+25
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,31 @@ def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item:
108108
assert item.status_code == 200, item.text
109109

110110

111+
def test_client_response_by_pass(TestCoreClient, item_dict):
112+
"""Check with `enable_direct_response` option."""
113+
114+
class InValidResponseClient(TestCoreClient):
115+
def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item:
116+
item_dict.pop("bbox", None)
117+
item_dict.pop("geometry", None)
118+
return item_dict
119+
120+
test_app = app.StacApi(
121+
settings=ApiSettings(
122+
enable_response_models=False,
123+
enable_direct_response=True,
124+
),
125+
client=InValidResponseClient(),
126+
)
127+
128+
with TestClient(test_app.app) as client:
129+
item = client.get("/collections/test/items/test")
130+
131+
assert item.json()
132+
assert item.status_code == 200
133+
assert item.headers["content-type"] == "application/geo+json"
134+
135+
111136
def test_client_openapi(TestCoreClient):
112137
"""Test if response models are all documented with OpenAPI."""
113138

stac_fastapi/types/stac_fastapi/types/config.py

+17
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
from typing import Optional
44

5+
from pydantic import model_validator
56
from pydantic_settings import BaseSettings, SettingsConfigDict
7+
from typing_extensions import Self
68

79

810
class ApiSettings(BaseSettings):
@@ -27,14 +29,29 @@ class ApiSettings(BaseSettings):
2729
app_host: str = "0.0.0.0"
2830
app_port: int = 8000
2931
reload: bool = True
32+
33+
# Enable Pydantic validation for output Response
3034
enable_response_models: bool = False
3135

36+
# Enable direct `Response` from endpoint, skipping validation and serialization
37+
enable_direct_response: bool = False
38+
3239
openapi_url: str = "/api"
3340
docs_url: str = "/api.html"
3441
root_path: str = ""
3542

3643
model_config = SettingsConfigDict(env_file=".env", extra="allow")
3744

45+
@model_validator(mode="after")
46+
def check_incompatible_options(self) -> Self:
47+
"""Check for incompatible options."""
48+
if self.enable_response_models and self.enable_direct_response:
49+
raise ValueError(
50+
"`enable_reponse_models` and `enable_direct_response` options are incompatible" # noqa: E501
51+
)
52+
53+
return self
54+
3855

3956
class Settings:
4057
"""Holds the global instance of settings."""
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""test config classes."""
2+
3+
import pytest
4+
from pydantic import ValidationError
5+
6+
from stac_fastapi.types.config import ApiSettings
7+
8+
9+
def test_incompatible_options():
10+
"""test incompatible output model options."""
11+
settings = ApiSettings(
12+
enable_response_models=True,
13+
enable_direct_response=False,
14+
)
15+
assert settings.enable_response_models
16+
assert not settings.enable_direct_response
17+
18+
settings = ApiSettings(
19+
enable_response_models=False,
20+
enable_direct_response=True,
21+
)
22+
assert not settings.enable_response_models
23+
assert settings.enable_direct_response
24+
25+
with pytest.raises(ValidationError):
26+
ApiSettings(
27+
enable_response_models=True,
28+
enable_direct_response=True,
29+
)

0 commit comments

Comments
 (0)