Skip to content

Commit f5744d0

Browse files
authored
Merge pull request #27 from modern-python/22-feature-add-swagger-instrument
implement swagger instrument
2 parents 85f9e7c + e2dc5c7 commit f5744d0

19 files changed

+141995
-15
lines changed

lite_bootstrap/bootstrappers/fastapi_bootstrapper.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from lite_bootstrap import import_checker
55
from lite_bootstrap.bootstrappers.base import BaseBootstrapper
6+
from lite_bootstrap.fastapi_offline_docs.main import enable_offline_docs
67
from lite_bootstrap.instruments.cors_instrument import CorsConfig, CorsInstrument
78
from lite_bootstrap.instruments.healthchecks_instrument import (
89
HealthChecksConfig,
@@ -13,6 +14,7 @@
1314
from lite_bootstrap.instruments.opentelemetry_instrument import OpentelemetryConfig, OpenTelemetryInstrument
1415
from lite_bootstrap.instruments.prometheus_instrument import PrometheusConfig, PrometheusInstrument
1516
from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument
17+
from lite_bootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument
1618

1719

1820
if import_checker.is_fastapi_installed:
@@ -28,7 +30,9 @@
2830

2931

3032
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
31-
class FastAPIConfig(CorsConfig, HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusConfig, SentryConfig):
33+
class FastAPIConfig(
34+
CorsConfig, HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusConfig, SentryConfig, SwaggerConfig
35+
):
3236
application: "fastapi.FastAPI" = dataclasses.field(default_factory=lambda: fastapi.FastAPI())
3337
opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list)
3438
prometheus_instrumentator_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
@@ -129,6 +133,18 @@ def bootstrap(self) -> None:
129133
)
130134

131135

136+
@dataclasses.dataclass(kw_only=True, frozen=True)
137+
class FastApiSwaggerInstrument(SwaggerInstrument):
138+
bootstrap_config: FastAPIConfig
139+
140+
def bootstrap(self) -> None:
141+
self.bootstrap_config.application.docs_url = self.bootstrap_config.swagger_path
142+
if self.bootstrap_config.swagger_offline_docs:
143+
enable_offline_docs(
144+
self.bootstrap_config.application, static_files_handler=self.bootstrap_config.service_static_path
145+
)
146+
147+
132148
class FastAPIBootstrapper(BaseBootstrapper["fastapi.FastAPI"]):
133149
__slots__ = "bootstrap_config", "instruments"
134150

@@ -139,6 +155,7 @@ class FastAPIBootstrapper(BaseBootstrapper["fastapi.FastAPI"]):
139155
FastAPIHealthChecksInstrument,
140156
FastAPILoggingInstrument,
141157
FastAPIPrometheusInstrument,
158+
FastApiSwaggerInstrument,
142159
]
143160
bootstrap_config: FastAPIConfig
144161
not_ready_message = "fastapi is not installed"

lite_bootstrap/bootstrappers/litestar_bootstrapper.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import dataclasses
2+
import pathlib
23
import typing
34

45
from lite_bootstrap import import_checker
@@ -18,22 +19,32 @@
1819
PrometheusInstrument,
1920
)
2021
from lite_bootstrap.instruments.sentry_instrument import SentryConfig, SentryInstrument
22+
from lite_bootstrap.instruments.swagger_instrument import SwaggerConfig, SwaggerInstrument
2123

2224

2325
if import_checker.is_litestar_installed:
2426
import litestar
2527
from litestar.config.app import AppConfig
2628
from litestar.config.cors import CORSConfig
2729
from litestar.contrib.opentelemetry import OpenTelemetryConfig
30+
from litestar.openapi import OpenAPIConfig
31+
from litestar.openapi.plugins import SwaggerRenderPlugin
2832
from litestar.plugins.prometheus import PrometheusConfig, PrometheusController
33+
from litestar.static_files import create_static_files_router
2934

3035
if import_checker.is_opentelemetry_installed:
3136
from opentelemetry.trace import get_tracer_provider
3237

3338

3439
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
3540
class LitestarConfig(
36-
CorsConfig, HealthChecksConfig, LoggingConfig, OpentelemetryConfig, PrometheusBootstrapperConfig, SentryConfig
41+
CorsConfig,
42+
HealthChecksConfig,
43+
LoggingConfig,
44+
OpentelemetryConfig,
45+
PrometheusBootstrapperConfig,
46+
SentryConfig,
47+
SwaggerConfig,
3748
):
3849
application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig())
3950
opentelemetry_excluded_urls: list[str] = dataclasses.field(default_factory=list)
@@ -130,6 +141,41 @@ class LitestarPrometheusController(PrometheusController):
130141
self.bootstrap_config.application_config.middleware.append(litestar_prometheus_config.middleware)
131142

132143

144+
@dataclasses.dataclass(kw_only=True, frozen=True)
145+
class LitestarSwaggerInstrument(SwaggerInstrument):
146+
bootstrap_config: LitestarConfig
147+
148+
def bootstrap(self) -> None:
149+
render_plugins: typing.Final = (
150+
(
151+
SwaggerRenderPlugin(
152+
js_url=f"{self.bootstrap_config.service_static_path}/swagger-ui-bundle.js",
153+
css_url=f"{self.bootstrap_config.service_static_path}/swagger-ui.css",
154+
standalone_preset_js_url=(
155+
f"{self.bootstrap_config.service_static_path}/swagger-ui-standalone-preset.js"
156+
),
157+
),
158+
)
159+
if self.bootstrap_config.swagger_offline_docs
160+
else (SwaggerRenderPlugin(),)
161+
)
162+
self.bootstrap_config.application_config.openapi_config = OpenAPIConfig(
163+
path=self.bootstrap_config.swagger_path,
164+
title=self.bootstrap_config.service_name,
165+
version=self.bootstrap_config.service_version,
166+
description=self.bootstrap_config.service_description,
167+
render_plugins=render_plugins,
168+
**self.bootstrap_config.swagger_extra_params,
169+
)
170+
if self.bootstrap_config.swagger_offline_docs:
171+
static_dir_path = pathlib.Path(__file__).parent.parent / "litestar_swagger_static"
172+
self.bootstrap_config.application_config.route_handlers.append(
173+
create_static_files_router(
174+
path=self.bootstrap_config.service_static_path, directories=[static_dir_path]
175+
)
176+
)
177+
178+
133179
class LitestarBootstrapper(BaseBootstrapper["litestar.Litestar"]):
134180
__slots__ = "bootstrap_config", "instruments"
135181

@@ -140,6 +186,7 @@ class LitestarBootstrapper(BaseBootstrapper["litestar.Litestar"]):
140186
LitestarHealthChecksInstrument,
141187
LitestarLoggingInstrument,
142188
LitestarPrometheusInstrument,
189+
LitestarSwaggerInstrument,
143190
]
144191
bootstrap_config: LitestarConfig
145192
not_ready_message = "litestar is not installed"

lite_bootstrap/fastapi_offline_docs/__init__.py

Whitespace-only changes.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import os
2+
import pathlib
3+
import typing
4+
5+
from lite_bootstrap import import_checker
6+
7+
8+
if import_checker.is_fastapi_installed:
9+
from fastapi import FastAPI, Request
10+
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html
11+
from fastapi.responses import HTMLResponse
12+
from fastapi.staticfiles import StaticFiles
13+
from starlette.routing import Route
14+
15+
16+
def enable_offline_docs(
17+
app: "FastAPI",
18+
static_files_handler: str = "/static",
19+
static_dir_path: os.PathLike[str] = pathlib.Path(__file__).parent / "static",
20+
include_docs_in_schema: bool = False,
21+
) -> None:
22+
if not (app_openapi_url := app.openapi_url):
23+
msg = "No app.openapi_url specified"
24+
raise RuntimeError(msg)
25+
26+
docs_url: str = app.docs_url or "/docs"
27+
redoc_url: str = app.redoc_url or "/redoc"
28+
swagger_ui_oauth2_redirect_url: str = app.swagger_ui_oauth2_redirect_url or "/docs/oauth2-redirect"
29+
30+
app.router.routes = [
31+
route
32+
for route in app.router.routes
33+
if typing.cast(Route, route).path not in (docs_url, redoc_url, swagger_ui_oauth2_redirect_url)
34+
]
35+
36+
app.mount(static_files_handler, StaticFiles(directory=static_dir_path), name=static_files_handler)
37+
38+
@app.get(docs_url, include_in_schema=include_docs_in_schema)
39+
async def custom_swagger_ui_html(request: Request) -> HTMLResponse:
40+
root_path = typing.cast(str, request.scope.get("root_path", "").rstrip("/"))
41+
swagger_js_url = f"{root_path}{static_files_handler}/swagger-ui-bundle.js"
42+
swagger_css_url = f"{root_path}{static_files_handler}/swagger-ui.css"
43+
return get_swagger_ui_html(
44+
openapi_url=root_path + app_openapi_url,
45+
title=f"{app.title} - Swagger UI",
46+
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
47+
swagger_js_url=swagger_js_url,
48+
swagger_css_url=swagger_css_url,
49+
)
50+
51+
@app.get(swagger_ui_oauth2_redirect_url, include_in_schema=include_docs_in_schema)
52+
async def swagger_ui_redirect() -> HTMLResponse:
53+
return get_swagger_ui_oauth2_redirect_html()
54+
55+
@app.get(redoc_url, include_in_schema=include_docs_in_schema)
56+
async def redoc_html() -> HTMLResponse:
57+
return get_redoc_html(
58+
openapi_url=app_openapi_url,
59+
title=f"{app.title} - ReDoc",
60+
redoc_js_url=f"{static_files_handler}/redoc.standalone.js",
61+
)

lite_bootstrap/fastapi_offline_docs/static/redoc.standalone.js

Lines changed: 1782 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lite_bootstrap/fastapi_offline_docs/static/swagger-ui-bundle.js

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lite_bootstrap/fastapi_offline_docs/static/swagger-ui.css

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lite_bootstrap/helpers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import re
2+
import typing
3+
4+
5+
VALID_PATH_PATTERN: typing.Final = re.compile(r"^(/[a-zA-Z0-9_-]+)+/?$")
6+
7+
8+
def is_valid_path(maybe_path: str) -> bool:
9+
return bool(re.fullmatch(VALID_PATH_PATTERN, maybe_path))

lite_bootstrap/instruments/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
66
class BaseConfig:
77
service_name: str = "micro-service"
8+
service_description: str | None = None
89
service_version: str = "1.0.0"
910
service_environment: str | None = None
1011
service_debug: bool = True
Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
import dataclasses
2-
import re
3-
import typing
42

3+
from lite_bootstrap.helpers import is_valid_path
54
from lite_bootstrap.instruments.base import BaseConfig, BaseInstrument
65

76

8-
VALID_PATH_PATTERN: typing.Final = re.compile(r"^(/[a-zA-Z0-9_-]+)+/?$")
9-
10-
11-
def _is_valid_path(maybe_path: str) -> bool:
12-
return bool(re.fullmatch(VALID_PATH_PATTERN, maybe_path))
13-
14-
157
@dataclasses.dataclass(kw_only=True, frozen=True)
168
class PrometheusConfig(BaseConfig):
179
prometheus_metrics_path: str = "/metrics"
@@ -24,6 +16,6 @@ class PrometheusInstrument(BaseInstrument):
2416
not_ready_message = "prometheus_metrics_path is empty or not valid"
2517

2618
def is_ready(self) -> bool:
27-
return bool(self.bootstrap_config.prometheus_metrics_path) and _is_valid_path(
19+
return bool(self.bootstrap_config.prometheus_metrics_path) and is_valid_path(
2820
self.bootstrap_config.prometheus_metrics_path
2921
)

0 commit comments

Comments
 (0)