Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix content-type of /api response #287

Merged
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
### Fixed

* Links stored with Collections and Items (e.g. license links) are now returned with those STAC objects ([#282](https://github.com/stac-utils/stac-fastapi/pull/282))
* Content-type response headers for the /api endpoint now reflect those expected in the STAC api spec ([#287](https://github.com/stac-utils/stac-fastapi/pull/287))

## [2.2.0]

Expand Down
77 changes: 74 additions & 3 deletions stac_fastapi/api/stac_fastapi/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@
import attr
from brotli_asgi import BrotliMiddleware
from fastapi import APIRouter, FastAPI
from fastapi.openapi.docs import (
get_redoc_html,
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html,
)
from fastapi.openapi.utils import get_openapi
from fastapi.responses import ORJSONResponse
from pydantic import BaseModel
from stac_pydantic import Collection, Item, ItemCollection
from stac_pydantic.api import ConformanceClasses, LandingPage, Search
from stac_pydantic.api.collections import Collections
from stac_pydantic.version import STAC_VERSION
from starlette.responses import JSONResponse, Response
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response

from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers
from stac_fastapi.api.models import (
Expand All @@ -32,6 +39,69 @@
from stac_fastapi.types.search import STACSearch


class VndOaiResponse(ORJSONResponse):
"""JSON with custom, vendor content-type."""

media_type = "application/vnd.oai.openapi+json;version=3.0"


class VndFastAPI(FastAPI):
"""Custom FastAPI to support /api response with vendor content-type."""

def setup(self) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps unnecessary code duplication can be avoided by calling super and then removing/re-adding the route itself.

self is a Starlette object, which has a routes property that directly returns the list of the router's routes. AFAIK adding a route appends it to the list and does no other special registration. While there's no "remove_route" method, if you find the route with the self.openapi_url and replace it with the one you construct with the proper response, that might save some code dupe. The worry with the dupe is that if a later version of FastAPI changes say the docs_url add logic, then this override logic won't pick up on those changes unrelated to the VndOaiResponse.

Another thing to note is that Route exposes the endpoint Callable directly, so you could even wrap the endpoint in one that translates the return value into a VndOaiResponse.

Maybe not worth spending too much time trying to hack the internals of FastAPI and Starlette (though everything I mentioned is public API), but I think it would be preferable to reduce the code copy in the setup override to only as much as is needed for this small change.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears as though attrs has a converter argument, which allows you to make modifications to supplied arguments. I've implemented a function which can be used as a converter to alter the routes such that the appropriate vendor content-type is advertised without any inheritance shenanigans

"""Update vendor-specific content-type for /api."""
if self.openapi_url:
urls = (server_data.get("url") for server_data in self.servers)
server_urls = {url for url in urls if url}

async def openapi(req: Request) -> JSONResponse:
root_path = req.scope.get("root_path", "").rstrip("/")
if root_path not in server_urls:
if root_path and self.root_path_in_servers:
self.servers.insert(0, {"url": root_path})
server_urls.add(root_path)
return VndOaiResponse(self.openapi())

self.add_route(self.openapi_url, openapi, include_in_schema=False)
if self.openapi_url and self.docs_url:

async def swagger_ui_html(req: Request) -> HTMLResponse:
root_path = req.scope.get("root_path", "").rstrip("/")
openapi_url = root_path + self.openapi_url
oauth2_redirect_url = self.swagger_ui_oauth2_redirect_url
if oauth2_redirect_url:
oauth2_redirect_url = root_path + oauth2_redirect_url
return get_swagger_ui_html(
openapi_url=openapi_url,
title=self.title + " - Swagger UI",
oauth2_redirect_url=oauth2_redirect_url,
init_oauth=self.swagger_ui_init_oauth,
)

self.add_route(self.docs_url, swagger_ui_html, include_in_schema=False)

if self.swagger_ui_oauth2_redirect_url:

async def swagger_ui_redirect(req: Request) -> HTMLResponse:
return get_swagger_ui_oauth2_redirect_html()

self.add_route(
self.swagger_ui_oauth2_redirect_url,
swagger_ui_redirect,
include_in_schema=False,
)
if self.openapi_url and self.redoc_url:

async def redoc_html(req: Request) -> HTMLResponse:
root_path = req.scope.get("root_path", "").rstrip("/")
openapi_url = root_path + self.openapi_url
return get_redoc_html(
openapi_url=openapi_url, title=self.title + " - ReDoc"
)

self.add_route(self.redoc_url, redoc_html, include_in_schema=False)


@attr.s
class StacApi:
"""StacApi factory.
Expand Down Expand Up @@ -64,7 +134,8 @@ class StacApi:
)
app: FastAPI = attr.ib(
default=attr.Factory(
lambda self: FastAPI(openapi_url=self.settings.openapi_url), takes_self=True
lambda self: VndFastAPI(openapi_url=self.settings.openapi_url),
takes_self=True,
)
)
router: APIRouter = attr.ib(default=attr.Factory(APIRouter))
Expand All @@ -75,7 +146,7 @@ class StacApi:
search_request_model: Type[Search] = attr.ib(default=STACSearch)
search_get_request: Type[SearchGetRequest] = attr.ib(default=SearchGetRequest)
item_collection_uri: Type[ItemCollectionUri] = attr.ib(default=ItemCollectionUri)
response_class: Type[Response] = attr.ib(default=JSONResponse)
response_class: Type[Response] = attr.ib(default=ORJSONResponse)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the default JSONResponse value to ORJSONResponse, which seems like an orthogonal change to this PR. And, orjson isn't an explicit dependency to anything but pgstac, so either orjson should be added as a dependency to the api project or it shouldn't be used in this code file (applies also to the base class of `VndOaiResponse), as it would break installations that only relied on the sqlalchemy backend.

middlewares: List = attr.ib(default=attr.Factory(lambda: [BrotliMiddleware]))

def get_extension(self, extension: Type[ApiExtension]) -> Optional[ApiExtension]:
Expand Down
9 changes: 9 additions & 0 deletions stac_fastapi/pgstac/tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
]


@pytest.mark.asyncio
async def test_api_headers(app_client):
resp = await app_client.get("/api")
assert (
resp.headers["content-type"] == "application/vnd.oai.openapi+json;version=3.0"
)
assert resp.status_code == 200


@pytest.mark.asyncio
async def test_core_router(api_client):
core_routes = set(STAC_CORE_ROUTES)
Expand Down
8 changes: 8 additions & 0 deletions stac_fastapi/sqlalchemy/tests/api/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@
]


def test_api_headers(app_client):
resp = app_client.get("/api")
assert (
resp.headers["content-type"] == "application/vnd.oai.openapi+json;version=3.0"
)
assert resp.status_code == 200


def test_core_router(api_client):
core_routes = set(STAC_CORE_ROUTES)
api_routes = set(
Expand Down