diff --git a/CHANGES.md b/CHANGES.md index d34885b..f4b6ae5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,8 @@ - Fixed datatype of ctx.path (it was starlette.URL, now it is pydantic_core.Url). +- Replaced FastAPI's favicon.ico with `/favicon.ico`. + - Deprecate `dramatiq` submodule. diff --git a/clean_python/fastapi/schema.py b/clean_python/fastapi/schema.py index 94cd013..2f1df07 100644 --- a/clean_python/fastapi/schema.py +++ b/clean_python/fastapi/schema.py @@ -3,14 +3,54 @@ import yaml from fastapi import FastAPI +from fastapi import Request from fastapi import Response +from fastapi.openapi.docs import get_redoc_html +from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.responses import HTMLResponse + +OPENAPI_URL = "/openapi.json" +FAVICON_URL = "/favicon.ico" def add_cached_openapi_yaml(app: FastAPI) -> None: - @app.get("/openapi.yaml", include_in_schema=False) + @app.get(OPENAPI_URL.replace(".json", ".yaml"), include_in_schema=False) @lru_cache def openapi_yaml() -> Response: openapi_json = app.openapi() yaml_s = StringIO() yaml.dump(openapi_json, yaml_s) return Response(yaml_s.getvalue(), media_type="text/yaml") + + +def get_openapi_url(request: Request) -> str: + root_path = request.scope.get("root_path", "").rstrip("/") + return root_path + OPENAPI_URL + + +def add_swagger_ui(app: FastAPI, title: str, client_id: str | None) -> None: + # Code below is copied from fastapi.applications to modify the favicon + @app.get("/docs", include_in_schema=False) + async def swagger_ui_html(request: Request) -> HTMLResponse: + return get_swagger_ui_html( + openapi_url=get_openapi_url(request), + title=f"{title} - Swagger UI", + swagger_favicon_url=FAVICON_URL, + init_oauth={ + "clientId": client_id, + "usePkceWithAuthorizationCodeGrant": True, + } + if client_id + else None, + ) + + +def add_redoc(app: FastAPI, title: str) -> None: + # Code below is copied from fastapi.applications to modify the favicon + @app.get("/redoc", include_in_schema=False) + async def redoc_html(request: Request) -> HTMLResponse: + return get_redoc_html( + openapi_url=get_openapi_url(request), + title=f"{title} - ReDoc", + redoc_favicon_url=FAVICON_URL, + ) diff --git a/clean_python/fastapi/service.py b/clean_python/fastapi/service.py index 56d3d84..95a665d 100644 --- a/clean_python/fastapi/service.py +++ b/clean_python/fastapi/service.py @@ -35,6 +35,8 @@ from .resource import clean_resources from .resource import Resource from .schema import add_cached_openapi_yaml +from .schema import add_redoc +from .schema import add_swagger_ui from .security import AuthSettings from .security import OAuth2Schema from .security import set_auth_scheme @@ -42,19 +44,6 @@ __all__ = ["Service"] -def get_swagger_ui_init_oauth( - auth: AuthSettings | None = None, -) -> dict[str, Any] | None: - return ( - None - if auth is None or not auth.oauth2.login_enabled() - else { - "clientId": auth.oauth2.client_id, - "usePkceWithAuthorizationCodeGrant": True, - } - ) - - AnyHttpUrlTA = TypeAdapter(AnyHttpUrl) @@ -118,6 +107,7 @@ def _create_root_app( for x in self.versions ], root_path_in_servers=False, + openapi_url=None, # disables redoc and docs as well ) if access_logger_gateway is not None: app.middleware("http")( @@ -127,16 +117,26 @@ def _create_root_app( return app def _create_versioned_app( - self, version: APIVersion, auth_scheme: OAuth2Schema | None, **fastapi_kwargs + self, + version: APIVersion, + auth_scheme: OAuth2Schema | None, + title: str, + description: str, + dependencies: list[Depends], + client_id: str | None, ) -> FastAPI: resources = [x for x in self.resources if x.version == version] app = FastAPI( + title=title, + description=description, + dependencies=dependencies, version=version.prefix, tags=sorted( [x.get_openapi_tag().model_dump() for x in resources], key=lambda x: x["name"], ), - **fastapi_kwargs, + redoc_url=None, # added manually later in add_redoc + docs_url=None, # added manually later in add_swagger_ui ) for resource in resources: app.include_router( @@ -156,6 +156,8 @@ def _create_versioned_app( app.add_exception_handler(PermissionDenied, permission_denied_handler) app.add_exception_handler(Unauthorized, unauthorized_handler) add_cached_openapi_yaml(app) + add_swagger_ui(app, title=title, client_id=client_id) + add_redoc(app, title=title) return app def create_app( @@ -177,14 +179,18 @@ def create_app( on_shutdown=on_shutdown, access_logger_gateway=access_logger_gateway, ) - fastapi_kwargs = { - "title": title, - "description": description, - "dependencies": [Depends(set_request_context)], - "swagger_ui_init_oauth": get_swagger_ui_init_oauth(auth), - } + dependencies = [Depends(set_request_context)] versioned_apps = { - v: self._create_versioned_app(v, auth_scheme=auth_scheme, **fastapi_kwargs) + v: self._create_versioned_app( + v, + auth_scheme=auth_scheme, + title=title, + description=description, + dependencies=dependencies, + client_id=auth.oauth2.client_id + if auth and auth.oauth2.login_enabled() + else None, + ) for v in self.versions } for v, versioned_app in versioned_apps.items(): diff --git a/tests/fastapi/test_service_schema.py b/tests/fastapi/test_service_schema.py index 3a5d2de..9c0500d 100644 --- a/tests/fastapi/test_service_schema.py +++ b/tests/fastapi/test_service_schema.py @@ -1,3 +1,4 @@ +from html.parser import HTMLParser from http import HTTPStatus import pytest @@ -150,3 +151,26 @@ def test_schema_yaml(client: TestClient, expected_schema: Json): actual = yaml.safe_load(response.content.decode("utf-8")) assert actual == expected_schema + + +@pytest.mark.parametrize("path", ["/v1/docs", "/v1/redoc"]) +def test_favicon(client: TestClient, path: str): + response = client.get(path) + assert response.status_code == HTTPStatus.OK + + # parse favicon from html + found = set() + + class FaviconParser(HTMLParser): + def handle_starttag(self, tag, attrs): + if tag == "link": + attr_dict = dict(attrs) + if ( + attr_dict.get("rel") in {"icon", "shortcut icon"} + and "href" in attr_dict + ): + found.add(attr_dict["href"]) + + FaviconParser().feed(response.text) + + assert found == {"/favicon.ico"}