Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/src/tips-and-tricks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
10 changes: 9 additions & 1 deletion stac_fastapi/api/stac_fastapi/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
38 changes: 35 additions & 3 deletions stac_fastapi/api/stac_fastapi/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
25 changes: 25 additions & 0 deletions stac_fastapi/api/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
17 changes: 17 additions & 0 deletions stac_fastapi/types/stac_fastapi/types/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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."""
Expand Down
29 changes: 29 additions & 0 deletions stac_fastapi/types/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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,
)