diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7913fe16..83fc72a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,13 +40,13 @@ repos: - id: chmod args: ['644'] exclude_types: [shell] - exclude: ^(.*__main__\.py)$ + exclude: ^(.*__main__\.py|syncmaster/backend/export_openapi_schema\.py)$ - id: chmod args: ['755'] types: [shell] - id: chmod args: ['755'] - files: ^(.*__main__\.py)$ + files: ^(.*__main__\.py|syncmaster/backend/export_openapi_schema\.py)$ - id: insert-license files: .*\.py$ exclude: ^(syncmaster/backend/dependencies/stub.py|docs/.*\.py|tests/.*\.py)$ diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1e304566..094f3868 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -80,6 +80,9 @@ services: command: --loglevel=info -Q test_queue entrypoint: [python, -m, celery, -A, tests.test_integration.celery_test, worker, --max-tasks-per-child=1] env_file: .env.docker + environment: + # CI runs tests in the worker container, so we need to turn off interaction with static files for it + - SYNCMASTER__SERVER__STATIC_FILES__ENABLED=false volumes: - ./syncmaster:/app/syncmaster - ./cached_jars:/root/.ivy2 diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 4ae2b5ea..75f1066b 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -19,6 +19,20 @@ ENTRYPOINT ["/app/entrypoint.sh"] FROM base AS prod COPY ./syncmaster/ /app/syncmaster/ +# add this when logo will be ready +# COPY ./docs/_static/*.svg ./syncmaster/backend/static/ + +# Swagger UI +ADD https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest/swagger-ui-bundle.js /app/syncmaster/backend/static/swagger/swagger-ui-bundle.js +ADD https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest/swagger-ui.css /app/syncmaster/backend/static/swagger/swagger-ui.css + +# Redoc +ADD https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js /app/syncmaster/backend/static/redoc/redoc.standalone.js + +ENV SYNCMASTER__SERVER__OPENAPI__SWAGGER__JS_URL=/static/swagger/swagger-ui-bundle.js \ + SYNCMASTER__SERVER__OPENAPI__SWAGGER__CSS_URL=/static/swagger/swagger-ui.css \ + SYNCMASTER__SERVER__OPENAPI__REDOC__JS_URL=/static/redoc/redoc.standalone.js \ + SYNCMASTER__SERVER__STATIC_FILES__DIRECTORY=/app/syncmaster/backend/static FROM base as test diff --git a/docs/backend/configuration/index.rst b/docs/backend/configuration/index.rst index de4198ed..ba68d1df 100644 --- a/docs/backend/configuration/index.rst +++ b/docs/backend/configuration/index.rst @@ -14,6 +14,8 @@ Configuration cors debug monitoring + static_files + openapi .. autopydantic_settings:: syncmaster.settings.Settings .. autopydantic_settings:: syncmaster.settings.server.ServerSettings diff --git a/docs/backend/configuration/openapi.rst b/docs/backend/configuration/openapi.rst new file mode 100644 index 00000000..d7f19cb3 --- /dev/null +++ b/docs/backend/configuration/openapi.rst @@ -0,0 +1,12 @@ +.. _configuration-server-openapi: + +OpenAPI settings +================ + +These settings used to control exposing OpenAPI.json and SwaggerUI/ReDoc endpoints. + +.. autopydantic_model:: syncmaster.settings.server.openapi.OpenAPISettings +.. autopydantic_model:: syncmaster.settings.server.openapi.SwaggerSettings +.. autopydantic_model:: syncmaster.settings.server.openapi.RedocSettings +.. autopydantic_model:: syncmaster.settings.server.openapi.LogoSettings +.. autopydantic_model:: syncmaster.settings.server.openapi.FaviconSettings diff --git a/docs/backend/configuration/static_files.rst b/docs/backend/configuration/static_files.rst new file mode 100644 index 00000000..336d063f --- /dev/null +++ b/docs/backend/configuration/static_files.rst @@ -0,0 +1,8 @@ +.. _configuration-server-static-files: + +Serving static files +==================== + +These settings used to control serving static files by a server. + +.. autopydantic_model:: syncmaster.settings.server.static_files.StaticFilesSettings diff --git a/syncmaster/backend/__init__.py b/syncmaster/backend/__init__.py index 9bcdb397..946e645e 100644 --- a/syncmaster/backend/__init__.py +++ b/syncmaster/backend/__init__.py @@ -25,6 +25,10 @@ def application_factory(settings: Settings) -> FastAPI: application = FastAPI( title="Syncmaster", debug=settings.server.debug, + # will be set up by middlewares + openapi_url=None, + docs_url=None, + redoc_url=None, ) application.state.settings = settings application.include_router(api_router) diff --git a/syncmaster/backend/middlewares/__init__.py b/syncmaster/backend/middlewares/__init__.py index df7ff76a..1ecd6c54 100644 --- a/syncmaster/backend/middlewares/__init__.py +++ b/syncmaster/backend/middlewares/__init__.py @@ -8,7 +8,9 @@ from syncmaster.backend.middlewares.monitoring.metrics import ( apply_monitoring_metrics_middleware, ) +from syncmaster.backend.middlewares.openapi import apply_openapi_middleware from syncmaster.backend.middlewares.request_id import apply_request_id_middleware +from syncmaster.backend.middlewares.static_files import apply_static_files from syncmaster.settings import Settings @@ -24,5 +26,7 @@ def apply_middlewares( apply_cors_middleware(application, settings.server.cors) apply_monitoring_metrics_middleware(application, settings.server.monitoring) apply_request_id_middleware(application, settings.server.request_id) + apply_openapi_middleware(application, settings.server.openapi) + apply_static_files(application, settings.server.static_files) return application diff --git a/syncmaster/backend/middlewares/openapi.py b/syncmaster/backend/middlewares/openapi.py new file mode 100644 index 00000000..4b5abebe --- /dev/null +++ b/syncmaster/backend/middlewares/openapi.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2023-2024 MTS PJSC +# SPDX-License-Identifier: Apache-2.0 +from functools import partial + +from fastapi import 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 starlette.requests import Request +from starlette.responses import JSONResponse + +from syncmaster.settings.server.openapi import OpenAPISettings + + +async def custom_openapi(request: Request) -> JSONResponse: + app: FastAPI = request.app + root_path = request.scope.get("root_path", "").rstrip("/") + server_urls = set(filter(None, (server_data.get("url") for server_data in app.servers))) + + if root_path not in server_urls: + if root_path and app.root_path_in_servers: + app.servers.insert(0, {"url": root_path}) + server_urls.add(root_path) + + return JSONResponse(app.openapi()) + + +def custom_openapi_schema(app: FastAPI, settings: OpenAPISettings) -> dict: + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + openapi_version=app.openapi_version, + summary=app.summary, + description=app.description, + terms_of_service=app.terms_of_service, + contact=app.contact, + license_info=app.license_info, + routes=app.routes, + webhooks=app.webhooks.routes, + tags=app.openapi_tags, + servers=app.servers, + separate_input_output_schemas=app.separate_input_output_schemas, + ) + # https://redocly.com/docs/api-reference-docs/specification-extensions/x-logo/ + openapi_schema["info"]["x-logo"] = { + "url": str(settings.logo.url), + "altText": str(settings.logo.alt_text), + "backgroundColor": f"#{settings.logo.background_color}", # noqa: WPS237 + "href": str(settings.logo.href), + } + app.openapi_schema = openapi_schema + return app.openapi_schema + + +async def custom_swagger_ui_html(request: Request): + app: FastAPI = request.app + settings: OpenAPISettings = app.state.settings.server.openapi + root_path = request.scope.get("root_path", "").rstrip("/") + openapi_url = root_path + request.app.openapi_url # type: ignore[arg-type] + return get_swagger_ui_html( + openapi_url=openapi_url, + title=f"{app.title} - Swagger UI", + oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, + swagger_js_url=str(settings.swagger.js_url), + swagger_css_url=str(settings.swagger.css_url), + swagger_favicon_url=str(settings.favicon.url), + ) + + +async def custom_swagger_ui_redirect(request: Request): + return get_swagger_ui_oauth2_redirect_html() + + +async def custom_redoc_html(request: Request): + app: FastAPI = request.app + settings: OpenAPISettings = app.state.settings.server.openapi + root_path = request.scope.get("root_path", "").rstrip("/") + openapi_url = root_path + request.app.openapi_url # type: ignore[arg-type] + return get_redoc_html( + openapi_url=openapi_url, + title=f"{app.title} - ReDoc", + redoc_js_url=settings.redoc.js_url, + redoc_favicon_url=settings.favicon.url, + with_google_fonts=False, + ) + + +def apply_openapi_middleware(app: FastAPI, settings: OpenAPISettings) -> FastAPI: + """Add OpenAPI middleware to the application.""" + # https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/#include-the-custom-docs + if settings.enabled: + app.openapi_url = "/openapi.json" + app.add_route(app.openapi_url, custom_openapi, include_in_schema=False) + + if settings.swagger.enabled: + app.docs_url = "/docs" + app.swagger_ui_oauth2_redirect_url = "/docs/oauth2-redirect" + app.add_route(app.docs_url, custom_swagger_ui_html, include_in_schema=False) + app.add_route(app.swagger_ui_oauth2_redirect_url, custom_swagger_ui_redirect, include_in_schema=False) + + if settings.redoc.enabled: + app.redoc_url = "/redoc" + app.add_route(app.redoc_url, custom_redoc_html, include_in_schema=False) + + app.openapi = partial(custom_openapi_schema, app=app, settings=settings) # type: ignore[method-assign] + return app diff --git a/syncmaster/backend/middlewares/static_files.py b/syncmaster/backend/middlewares/static_files.py new file mode 100644 index 00000000..367a086f --- /dev/null +++ b/syncmaster/backend/middlewares/static_files.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2023-2024 MTS PJSC +# SPDX-License-Identifier: Apache-2.0 +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from syncmaster.settings.server.static_files import StaticFilesSettings + + +def apply_static_files(app: FastAPI, settings: StaticFilesSettings) -> FastAPI: + """Add static files serving middleware to the application.""" + if not settings.enabled: + return app + + # https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/#serve-the-static-files + app.mount("/static", StaticFiles(directory=settings.directory), name="static") + return app diff --git a/syncmaster/settings/server/__init__.py b/syncmaster/settings/server/__init__.py index fdd82d78..8acaf58a 100644 --- a/syncmaster/settings/server/__init__.py +++ b/syncmaster/settings/server/__init__.py @@ -8,7 +8,9 @@ from syncmaster.settings.log import LoggingSettings from syncmaster.settings.server.cors import CORSSettings from syncmaster.settings.server.monitoring import MonitoringSettings +from syncmaster.settings.server.openapi import OpenAPISettings from syncmaster.settings.server.request_id import RequestIDSettings +from syncmaster.settings.server.static_files import StaticFilesSettings class ServerSettings(BaseModel): @@ -47,3 +49,11 @@ class ServerSettings(BaseModel): default_factory=MonitoringSettings, description=":ref:`Monitoring settings `", ) + openapi: OpenAPISettings = Field( + default_factory=OpenAPISettings, + description=":ref:`OpenAPI.json settings `", + ) + static_files: StaticFilesSettings = Field( + default_factory=StaticFilesSettings, + description=":ref:`Static files settings `", + ) diff --git a/syncmaster/settings/server/openapi.py b/syncmaster/settings/server/openapi.py new file mode 100644 index 00000000..95e2d0bd --- /dev/null +++ b/syncmaster/settings/server/openapi.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2023-2024 MTS PJSC +# SPDX-License-Identifier: Apache-2.0 +import textwrap +from typing import Any + +from pydantic import AnyHttpUrl, BaseModel, Field + + +class SwaggerSettings(BaseModel): + """Swagger UI settings. + + SwaggerUI is served at ``/docs`` endpoint. + + Examples + -------- + + .. code-block:: bash + + SYNCMASTER__SERVER__OPENAPI__SWAGGER__ENABLED=True + SYNCMASTER__SERVER__OPENAPI__SWAGGER__JS_URL=/static/swagger/swagger-ui-bundle.js + SYNCMASTER__SERVER__OPENAPI__SWAGGER__CSS_URL=/static/swagger/swagger-ui.css + """ + + enabled: bool = Field(default=True, description="Set to ``True`` to enable Swagger UI endpoint") + js_url: str = Field( + default="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js", + description="URL for Swagger UI JS", + ) + css_url: str = Field( + default="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css", + description="URL for Swagger UI CSS", + ) + extra_parameters: dict[str, Any] = Field( + default_factory=dict, + description=textwrap.dedent( + """ + Additional parameters to pass to Swagger UI. + See `FastAPI documentation `_. + """, + ), + ) + + +class RedocSettings(BaseModel): + """ReDoc settings. + + ReDoc is served at ``/redoc`` endpoint. + + Examples + -------- + + .. code-block:: bash + + SYNCMASTER__SERVER__OPENAPI__REDOC__ENABLED=True + SYNCMASTER__SERVER__OPENAPI__REDOC__JS_URL=/static/redoc/redoc.standalone.js + """ + + enabled: bool = Field(default=True, description="Set to ``True`` to enable Redoc UI endpoint") + js_url: str = Field( + default="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js", + description="URL for Redoc UI JS, ``None`` to use default CDN URL", + ) + + +class LogoSettings(BaseModel): + """OpenAPI's ``x-logo`` documentation settings. + + See `OpenAPI spec `_ + for more details. + + Examples + -------- + + .. code-block:: bash + + SYNCMASTER__SERVER__OPENAPI__LOGO__URL=/static/logo.svg + SYNCMASTER__SERVER__OPENAPI__LOGO__BACKGROUND_COLOR=ffffff + SYNCMASTER__SERVER__OPENAPI__LOGO__ALT_TEXT=Syncmaster logo + SYNCMASTER__SERVER__OPENAPI__LOGO__HREF=http://mycompany.domain.com + """ + + url: str = Field( + default="/static/logo.svg", + description="URL for application logo", + ) + background_color: str = Field( + default="ffffff", + description="Background color in HEX RGB format, without ``#`` prefix", + ) + alt_text: str | None = Field( + default="Syncmaster logo", + description="Alternative text for ```` tag", + ) + href: AnyHttpUrl | None = Field( # type: ignore[assignment] + default="https://github.com/MobileTeleSystems/syncmaster", + description="Clicking on logo will redirect to this URL", + ) + + +class FaviconSettings(BaseModel): + """Favicon documentation settings. + + Examples + -------- + + .. code-block:: bash + + SYNCMASTER__SERVER__OPENAPI__FAVICON__URL=/static/icon.svg + """ + + url: str = Field( + default="/static/icon.svg", + description="URL for application favicon", + ) + + +class OpenAPISettings(BaseModel): + """OpenAPI Settings. + + OpenAPI.json is served at ``/openapi.json`` endpoint. + + Examples + -------- + + .. code-block:: bash + + SYNCMASTER__SERVER__OPENAPI__ENABLED=True + SYNCMASTER__SERVER__OPENAPI__SWAGGER__ENABLED=True + SYNCMASTER__SERVER__OPENAPI__REDOC__ENABLED=True + """ + + enabled: bool = Field(default=True, description="Set to ``True`` to enable OpenAPI.json endpoint") + swagger: SwaggerSettings = Field( + default_factory=SwaggerSettings, + description="Swagger UI settings", + ) + redoc: RedocSettings = Field( + default_factory=RedocSettings, + description="ReDoc UI settings", + ) + logo: LogoSettings = Field( + default_factory=LogoSettings, + description="Application logo settings", + ) + favicon: FaviconSettings = Field( + default_factory=FaviconSettings, + description="Application favicon settings", + ) diff --git a/syncmaster/settings/server/static_files.py b/syncmaster/settings/server/static_files.py new file mode 100644 index 00000000..20d4dde9 --- /dev/null +++ b/syncmaster/settings/server/static_files.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2023-2024 MTS PJSC +# SPDX-License-Identifier: Apache-2.0 +from pathlib import Path + +from pydantic import BaseModel, Field, field_validator + + +class StaticFilesSettings(BaseModel): + """Static files serving settings. + + Files are served at ``/static`` endpoint. + + Examples + -------- + + .. code-block:: bash + + SYNCMASTER__SERVER__STATIC_FILES__ENABLED=True + SYNCMASTER__SERVER__STATIC_FILES__DIRECTORY=/app/syncmaster/backend/static + """ + + enabled: bool = Field(default=True, description="Set to ``True`` to enable static file serving") + directory: Path = Field( + default=Path("docs/_static"), + description="Directory containing static files", + ) + + @field_validator("directory") + def _validate_directory(cls, value: Path) -> Path: + if not value.exists(): + raise ValueError(f"Directory '{value}' does not exist") + if not value.is_dir(): + raise ValueError(f"Path '{value}' is not a directory") + return value