qstd-openapi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qstd_openapi/sanic.py ADDED
@@ -0,0 +1,343 @@
1
+ """Sanic integration: route source, publishing endpoint and a documenting router.
2
+
3
+ Install with ``qstd-openapi[sanic]``.
4
+
5
+ Usage::
6
+
7
+ from qstd_openapi import OpenAPI
8
+ from qstd_openapi.sanic import OpenAPIBlueprint, SanicRoutes, mount
9
+
10
+ users = OpenAPIBlueprint(Blueprint('Users', url_prefix='/users'))
11
+
12
+ @users.post(
13
+ '/register',
14
+ tags=['Users'],
15
+ body=UserRegisterInput,
16
+ responses={201: UserDTO},
17
+ )
18
+ async def register_user(request): ...
19
+
20
+ app.blueprint(users.blueprint)
21
+ spec = OpenAPI(info={'title': 'Users API', 'version': '1.0.0'})
22
+ spec.include(SanicRoutes(app))
23
+ mount(app, spec, json_path='/openapi.json')
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import datetime
29
+ import importlib
30
+ import re
31
+ import uuid
32
+
33
+ from collections.abc import Iterable, Mapping, Sequence
34
+ from types import MappingProxyType
35
+ from typing import Any, Callable, Generic, Optional, TypeVar, Union, cast
36
+
37
+ from typing_extensions import Unpack
38
+
39
+ from qstd_openapi import openapi
40
+ from qstd_openapi.core.document import OpenAPI
41
+ from qstd_openapi.core.sources import HTTP_METHODS, RouteEntry, SourceEntry
42
+ from qstd_openapi.meta.model import Parameter
43
+ from qstd_openapi.serialization import dumps, dumps_yaml
44
+ from qstd_openapi.ui import DocsRenderer
45
+
46
+ try:
47
+ _sanic: Any = importlib.import_module('sanic')
48
+ except ImportError as exc: # pragma: no cover - extra not installed
49
+ raise ImportError(
50
+ 'qstd_openapi.sanic needs Sanic: install qstd-openapi[sanic]',
51
+ ) from exc
52
+
53
+ __all__ = ('OpenAPIBlueprint', 'SanicRouteOptions', 'SanicRoutes', 'mount')
54
+
55
+ F = TypeVar('F', bound=Callable[..., Any])
56
+ B = TypeVar('B')
57
+
58
+ _PARAMETER = re.compile(r'^<([^:>]+)(?::([^>]+))?>$')
59
+ _LABEL_TYPES: Mapping[str, Any] = MappingProxyType(
60
+ {
61
+ 'int': int,
62
+ 'float': float,
63
+ 'uuid': uuid.UUID,
64
+ 'ymd': datetime.date,
65
+ },
66
+ )
67
+ _DESCRIBE_OPTIONS = frozenset(openapi.DescribeOptions.__annotations__)
68
+ _SKIPPED_METHODS = frozenset({'head', 'options'})
69
+
70
+
71
+ def _path_and_parameters(parts: Sequence[str]) -> tuple[str, tuple[Parameter, ...]]:
72
+ """``('users', '<user_id:int>')`` -> ``('/users/{user_id}', (Parameter...))``."""
73
+ segments: list[str] = []
74
+ parameters: list[Parameter] = []
75
+ for part in parts:
76
+ match = _PARAMETER.match(part)
77
+ if match is None:
78
+ segments.append(part)
79
+ continue
80
+ name, label = match.group(1), match.group(2) or 'str'
81
+ segments.append(f'{{{name}}}')
82
+ parameters.append(Parameter('path', name, _LABEL_TYPES.get(label, str)))
83
+ return '/' + '/'.join(segment for segment in segments if segment), tuple(parameters)
84
+
85
+
86
+ class SanicRoutes:
87
+ """Operations of a Sanic application.
88
+
89
+ - path parameters come from the URL template with types from Sanic
90
+ labels (``int``, ``float``, ``uuid``, ``ymd``; anything else is a string);
91
+ - class-based views are documented per HTTP method;
92
+ - ``HEAD``/``OPTIONS``, static files and websockets are skipped;
93
+ - with ``blueprint_tags`` an operation without tags gets its blueprint's
94
+ name as the tag.
95
+
96
+ Routes are read when the document is built, so routes registered later
97
+ are included as long as the document is (re)built afterwards.
98
+ """
99
+
100
+ def __init__(self, app: Any, *, blueprint_tags: bool = True) -> None:
101
+ self.app = app
102
+ self.blueprint_tags = blueprint_tags
103
+
104
+ def collect(self) -> Iterable[SourceEntry]:
105
+ blueprints: dict[int, str] = {}
106
+ for name, blueprint in self.app.blueprints.items():
107
+ for route in blueprint.routes:
108
+ blueprints[id(route)] = name
109
+ seen: set[tuple[str, str, int]] = set()
110
+ entries: list[SourceEntry] = []
111
+ for route in sorted(self.app.router.routes, key=lambda r: (r.path, r.name)):
112
+ if route.extra.static or route.extra.websocket:
113
+ continue
114
+ path, parameters = _path_and_parameters(route.parts)
115
+ tags = (
116
+ (blueprints[id(route)],)
117
+ if (self.blueprint_tags and id(route) in blueprints)
118
+ else ()
119
+ )
120
+ name = _route_name(self.app.name, route.name)
121
+ for method in sorted(m.lower() for m in route.methods):
122
+ if method in _SKIPPED_METHODS or method not in HTTP_METHODS:
123
+ continue
124
+ handler = self._handler(route.handler, method)
125
+ # strict_slashes=False may register "/x" and "/x/" for one handler.
126
+ key = (path.rstrip('/') or '/', method, id(handler))
127
+ if key in seen:
128
+ continue
129
+ seen.add(key)
130
+ entries.append(
131
+ RouteEntry(path, method, handler, tags, parameters, name=name),
132
+ )
133
+ return entries
134
+
135
+ @staticmethod
136
+ def _handler(handler: Any, method: str) -> Any:
137
+ view_class = getattr(handler, 'view_class', None)
138
+ if view_class is not None:
139
+ return getattr(view_class, method, handler)
140
+ return handler
141
+
142
+
143
+ def _route_name(app_name: str, route_name: Optional[str]) -> Optional[str]:
144
+ """``'<app>.<blueprint>.<handler>'`` → ``'<blueprint>_<handler>'``."""
145
+ if not route_name:
146
+ return None
147
+ prefix = f'{app_name}.'
148
+ if route_name.startswith(prefix):
149
+ route_name = route_name[len(prefix) :]
150
+ return route_name.replace('.', '_')
151
+
152
+
153
+ def mount(
154
+ app: Any,
155
+ spec: OpenAPI,
156
+ *,
157
+ json_path: Optional[str] = '/openapi.json',
158
+ yaml_path: Optional[str] = None,
159
+ ui: Optional[Mapping[str, DocsRenderer]] = None,
160
+ spec_url: Optional[str] = None,
161
+ title: Optional[str] = None,
162
+ name: str = 'qstd_openapi',
163
+ decorators: Sequence[Callable[[Any], Any]] = (),
164
+ build_on_start: bool = True,
165
+ ) -> None:
166
+ """Publish the document of ``spec``.
167
+
168
+ - ``json_path`` / ``yaml_path``: endpoints with the document (YAML needs
169
+ the ``yaml`` extra); ``None`` disables one;
170
+ - ``ui``: ``{path: renderer}``, e.g. ``{'/docs': Redoc(), '/swagger': SwaggerUI()}``;
171
+ pages load the document from ``spec_url`` (default: ``json_path``, or
172
+ ``yaml_path`` if JSON is disabled) — set it when the app is served under a
173
+ prefix or behind a proxy;
174
+ - ``decorators`` wrap every endpoint (for example an auth check), innermost
175
+ last as with stacked decorators;
176
+ - ``build_on_start`` builds the document before the server starts, so a
177
+ broken description fails the start instead of the first request.
178
+
179
+ Route names are ``{name}_json``, ``{name}_yaml`` and ``{name}_ui_<n>``; the
180
+ endpoints are excluded from the document.
181
+ """
182
+ http = _sanic.response.HTTPResponse
183
+
184
+ async def openapi_json(request: Any) -> Any: # noqa: ARG001
185
+ return http(dumps(spec.build().document), content_type='application/json')
186
+
187
+ async def openapi_yaml(request: Any) -> Any: # noqa: ARG001
188
+ return http(dumps_yaml(spec.build().document), content_type='application/yaml')
189
+
190
+ if json_path:
191
+ _add_endpoint(app, openapi_json, json_path, f'{name}_json', decorators)
192
+ if yaml_path:
193
+ _add_endpoint(app, openapi_yaml, yaml_path, f'{name}_yaml', decorators)
194
+
195
+ document_url = spec_url or json_path or yaml_path
196
+ if ui and not document_url:
197
+ raise ValueError('ui pages need spec_url, json_path or yaml_path')
198
+ page_title = title or str(spec.info.get('title', 'API'))
199
+ for index, (path, renderer) in enumerate((ui or {}).items()):
200
+ page = renderer.render(spec_url=cast(str, document_url), title=page_title)
201
+
202
+ async def docs_page(request: Any, _page: str = page) -> Any: # noqa: ARG001
203
+ return http(_page, content_type='text/html; charset=utf-8')
204
+
205
+ _add_endpoint(app, docs_page, path, f'{name}_ui_{index}', decorators)
206
+
207
+ if build_on_start:
208
+
209
+ async def build_document(*_: Any) -> None:
210
+ spec.build()
211
+
212
+ app.register_listener(build_document, 'before_server_start')
213
+
214
+
215
+ def _add_endpoint(
216
+ app: Any,
217
+ endpoint: Callable[..., Any],
218
+ path: str,
219
+ name: str,
220
+ decorators: Sequence[Callable[[Any], Any]],
221
+ ) -> None:
222
+ handler: Any = openapi.exclude()(endpoint)
223
+ for decorator in reversed(decorators):
224
+ handler = decorator(handler)
225
+ if handler is not endpoint and hasattr(handler, '__dict__'):
226
+ openapi.exclude()(handler)
227
+ app.add_route(handler, path, methods=['GET'], name=name)
228
+
229
+
230
+ class SanicRouteOptions(openapi.DescribeOptions, total=False):
231
+ """``describe`` options plus Sanic's own route options."""
232
+
233
+ host: Union[str, list[str], None]
234
+ strict_slashes: Optional[bool]
235
+ stream: bool
236
+ version: Union[int, str, float, None]
237
+ name: Optional[str]
238
+ ignore_body: bool
239
+ apply: bool
240
+ subprotocols: Optional[list[str]]
241
+ websocket: bool
242
+ unquote: bool
243
+ static: bool
244
+ version_prefix: str
245
+ error_format: Optional[str]
246
+ ctx: Mapping[str, Any]
247
+ """Route context: ``ctx={'auth': False}`` is Sanic's ``ctx_auth=False``."""
248
+
249
+
250
+ class OpenAPIBlueprint(Generic[B]):
251
+ """A Sanic ``Blueprint`` (or app) whose route decorators also take ``describe`` options.
252
+
253
+ ``users.post('/register', tags=['Users'], body=Model, name='register')``
254
+ registers the route with Sanic (``name`` and other Sanic options are passed
255
+ through) and attaches the documentation options to the handler. The
256
+ wrapped object is ``.blueprint`` (typed); other attributes (``middleware``,
257
+ ``exception``...) are also delegated to it, untyped.
258
+ """
259
+
260
+ def __init__(self, blueprint: B) -> None:
261
+ self.blueprint: B = blueprint
262
+
263
+ def __getattr__(self, name: str) -> Any:
264
+ return getattr(self.blueprint, name)
265
+
266
+ def route(
267
+ self,
268
+ uri: str,
269
+ methods: Iterable[str] = ('GET',),
270
+ **options: Unpack[SanicRouteOptions],
271
+ ) -> Callable[[F], F]:
272
+ return self._register('route', uri, {'methods': list(methods), **options})
273
+
274
+ def get(self, uri: str, **options: Unpack[SanicRouteOptions]) -> Callable[[F], F]:
275
+ return self._register('get', uri, dict(options))
276
+
277
+ def post(self, uri: str, **options: Unpack[SanicRouteOptions]) -> Callable[[F], F]:
278
+ return self._register('post', uri, dict(options))
279
+
280
+ def put(self, uri: str, **options: Unpack[SanicRouteOptions]) -> Callable[[F], F]:
281
+ return self._register('put', uri, dict(options))
282
+
283
+ def patch(self, uri: str, **options: Unpack[SanicRouteOptions]) -> Callable[[F], F]:
284
+ return self._register('patch', uri, dict(options))
285
+
286
+ def delete(
287
+ self,
288
+ uri: str,
289
+ **options: Unpack[SanicRouteOptions],
290
+ ) -> Callable[[F], F]:
291
+ return self._register('delete', uri, dict(options))
292
+
293
+ def add_route(
294
+ self,
295
+ handler: F,
296
+ uri: str,
297
+ methods: Iterable[str] = ('GET',),
298
+ **options: Unpack[SanicRouteOptions],
299
+ ) -> F:
300
+ described, sanic_options = _split(dict(options))
301
+ self._describe(handler, described)
302
+ blueprint: Any = self.blueprint
303
+ blueprint.add_route(handler, uri, methods=list(methods), **sanic_options)
304
+ return handler
305
+
306
+ def _register(
307
+ self,
308
+ method: str,
309
+ uri: str,
310
+ options: Mapping[str, object],
311
+ ) -> Callable[[F], F]:
312
+ described, sanic_options = _split(options)
313
+ register = getattr(self.blueprint, method)(uri, **sanic_options)
314
+
315
+ def decorate(handler: F) -> F:
316
+ self._describe(handler, described)
317
+ register(handler)
318
+ return handler
319
+
320
+ return decorate
321
+
322
+ @staticmethod
323
+ def _describe(handler: Any, described: dict[str, Any]) -> None:
324
+ if not described:
325
+ return
326
+ view_class = getattr(handler, 'view_class', None)
327
+ targets = (
328
+ [getattr(view_class, m) for m in HTTP_METHODS if hasattr(view_class, m)]
329
+ if view_class is not None
330
+ else [handler]
331
+ )
332
+ for target in targets:
333
+ openapi.attach(target, **described)
334
+
335
+
336
+ def _split(options: Mapping[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
337
+ described = {k: v for k, v in options.items() if k in _DESCRIBE_OPTIONS}
338
+ rest = {
339
+ k: v for k, v in options.items() if k not in _DESCRIBE_OPTIONS and k != 'ctx'
340
+ }
341
+ for key, value in cast('Mapping[str, Any]', options.get('ctx', {})).items():
342
+ rest[f'ctx_{key}'] = value
343
+ return described, rest
@@ -0,0 +1,63 @@
1
+ """Stable JSON and YAML serialization for built OpenAPI documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+
8
+ from collections.abc import Mapping
9
+ from typing import Any, Optional, cast
10
+
11
+
12
+ def dumps(
13
+ document: Mapping[str, Any],
14
+ *,
15
+ canonical: bool = False,
16
+ indent: Optional[int] = None,
17
+ ) -> str:
18
+ """Serialize a document to JSON.
19
+
20
+ ``canonical=True`` sorts keys and drops whitespace, so the same inputs
21
+ always give byte-identical output.
22
+ """
23
+ if canonical:
24
+ return json.dumps(
25
+ document,
26
+ ensure_ascii=False,
27
+ sort_keys=True,
28
+ separators=(',', ':'),
29
+ )
30
+ return json.dumps(document, ensure_ascii=False, indent=indent)
31
+
32
+
33
+ def dumps_yaml(document: Mapping[str, Any], *, canonical: bool = False) -> str:
34
+ """Serialize a document to YAML (needs the ``yaml`` extra: PyYAML).
35
+
36
+ ``canonical=True`` sorts keys, so the same inputs give identical output.
37
+ """
38
+ try:
39
+ yaml: Any = importlib.import_module('yaml')
40
+ except ImportError as exc:
41
+ raise ImportError(
42
+ 'YAML output needs PyYAML: install qstd-openapi[yaml]',
43
+ ) from exc
44
+ return cast(
45
+ str,
46
+ yaml.safe_dump(
47
+ _plain(document),
48
+ sort_keys=canonical,
49
+ allow_unicode=True,
50
+ default_flow_style=False,
51
+ ),
52
+ )
53
+
54
+
55
+ def _plain(value: Any) -> Any:
56
+ """Plain dicts/lists so PyYAML's safe dumper accepts any Mapping/Sequence."""
57
+ if isinstance(value, Mapping):
58
+ return {
59
+ str(k): _plain(v) for k, v in cast('Mapping[object, object]', value).items()
60
+ }
61
+ if isinstance(value, (list, tuple)):
62
+ return [_plain(v) for v in cast('list[object]', value)]
63
+ return value
qstd_openapi/tags.py ADDED
@@ -0,0 +1,58 @@
1
+ """Tag descriptions kept as Markdown files next to the code.
2
+
3
+ A common layout keeps one file per tag, one directory per service or
4
+ document::
5
+
6
+ docs/openapi/users_api/Users.md
7
+ docs/openapi/users_api/Profile.md
8
+
9
+ Usage::
10
+
11
+ from qstd_openapi import OpenAPI, tags_from_markdown
12
+
13
+ spec = OpenAPI(
14
+ info={'title': 'Users API', 'version': '1.0.0'},
15
+ tags=tags_from_markdown('docs/openapi/users_api'),
16
+ )
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import copy
22
+ import os
23
+
24
+ from collections.abc import Mapping, Sequence
25
+ from pathlib import Path
26
+ from typing import Any, Union
27
+
28
+ __all__ = ('tags_from_markdown',)
29
+
30
+
31
+ def tags_from_markdown(
32
+ directory: Union[str, os.PathLike[str]],
33
+ tags: Sequence[Mapping[str, Any]] = (),
34
+ *,
35
+ encoding: str = 'utf-8',
36
+ ) -> list[dict[str, Any]]:
37
+ """Tag objects whose descriptions come from ``<directory>/<tag name>.md``.
38
+
39
+ ``tags`` are explicit tag objects: they keep their order and fields, and
40
+ a tag without a ``description`` gets the one from its file. Files for
41
+ other tags are appended, sorted by name. Hidden files and files with
42
+ other extensions are ignored; a missing directory is an error.
43
+ """
44
+ root = Path(directory)
45
+ texts = {
46
+ path.stem: path.read_text(encoding=encoding).strip()
47
+ for path in sorted(root.iterdir())
48
+ if path.is_file() and path.suffix == '.md' and not path.name.startswith('.')
49
+ }
50
+ result: list[dict[str, Any]] = []
51
+ for tag in tags:
52
+ item = copy.deepcopy(dict(tag))
53
+ text = texts.pop(str(item.get('name')), None)
54
+ if text and not item.get('description'):
55
+ item['description'] = text
56
+ result.append(item)
57
+ result.extend({'name': name, 'description': text} for name, text in texts.items())
58
+ return result
@@ -0,0 +1,98 @@
1
+ """Keeping the built document under version control.
2
+
3
+ The document is rendered with sorted keys and indentation, so a snapshot
4
+ committed next to the code shows every change of the API in review::
5
+
6
+ from qstd_openapi.testing import assert_matches_snapshot
7
+
8
+ def test_openapi_document() -> None:
9
+ assert_matches_snapshot(build_spec(), 'docs/openapi/users_api.json')
10
+
11
+ Run the tests with ``QSTD_OPENAPI_UPDATE_SNAPSHOTS=1`` to write (or
12
+ rewrite) snapshots after an intended change. The same check without pytest:
13
+ ``python -m qstd_openapi dump package.module:spec -o docs/openapi.json --check``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import difflib
19
+ import json
20
+ import os
21
+
22
+ from collections.abc import Mapping
23
+ from pathlib import Path
24
+ from typing import Any, Optional, Union
25
+
26
+ from qstd_openapi.core.document import OpenAPI
27
+ from qstd_openapi.serialization import dumps_yaml
28
+
29
+ __all__ = ('UPDATE_ENV', 'assert_matches_snapshot', 'diff', 'render')
30
+
31
+ UPDATE_ENV = 'QSTD_OPENAPI_UPDATE_SNAPSHOTS'
32
+ """Environment variable that makes :func:`assert_matches_snapshot` write snapshots."""
33
+
34
+ _MAX_DIFF_LINES = 200
35
+
36
+ Target = Union[OpenAPI, Mapping[str, Any]]
37
+
38
+
39
+ def render(target: Target, *, yaml: bool = False) -> str:
40
+ """The document of ``target`` (an ``OpenAPI`` or a built document) as stable text."""
41
+ document = target.build().document if isinstance(target, OpenAPI) else target
42
+ if yaml:
43
+ return dumps_yaml(document, canonical=True)
44
+ return json.dumps(document, ensure_ascii=False, sort_keys=True, indent=2) + '\n'
45
+
46
+
47
+ def diff(expected: str, actual: str, *, name: str = 'snapshot') -> str:
48
+ """A unified diff of two rendered documents, cut to a readable length."""
49
+ lines = list(
50
+ difflib.unified_diff(
51
+ expected.splitlines(keepends=True),
52
+ actual.splitlines(keepends=True),
53
+ fromfile=f'{name} (expected)',
54
+ tofile=f'{name} (built)',
55
+ ),
56
+ )
57
+ if len(lines) > _MAX_DIFF_LINES:
58
+ hidden = len(lines) - _MAX_DIFF_LINES
59
+ lines = [*lines[:_MAX_DIFF_LINES], f'... {hidden} more diff lines\n']
60
+ return ''.join(lines)
61
+
62
+
63
+ def assert_matches_snapshot(
64
+ target: Target,
65
+ path: Union[str, os.PathLike[str]],
66
+ *,
67
+ yaml: Optional[bool] = None,
68
+ update: Optional[bool] = None,
69
+ ) -> None:
70
+ """Fail with a diff when the document differs from the snapshot at ``path``.
71
+
72
+ ``yaml`` defaults to the file extension (``.yaml``/``.yml``); ``update``
73
+ defaults to the ``QSTD_OPENAPI_UPDATE_SNAPSHOTS`` environment variable.
74
+ In update mode the snapshot is written and the check passes.
75
+ """
76
+ snapshot = Path(path)
77
+ if yaml is None:
78
+ yaml = snapshot.suffix in ('.yaml', '.yml')
79
+ if update is None:
80
+ update = os.environ.get(UPDATE_ENV, '') not in ('', '0')
81
+ actual = render(target, yaml=yaml)
82
+ if update:
83
+ if not snapshot.exists() or snapshot.read_text(encoding='utf-8') != actual:
84
+ snapshot.parent.mkdir(parents=True, exist_ok=True)
85
+ snapshot.write_text(actual, encoding='utf-8')
86
+ return
87
+ if not snapshot.exists():
88
+ raise AssertionError(
89
+ f'OpenAPI snapshot {snapshot} does not exist; '
90
+ f'run with {UPDATE_ENV}=1 to create it',
91
+ )
92
+ expected = snapshot.read_text(encoding='utf-8')
93
+ if expected != actual:
94
+ raise AssertionError(
95
+ f'OpenAPI document differs from {snapshot}; if the change is '
96
+ f'intended, run with {UPDATE_ENV}=1 to update it.\n'
97
+ + diff(expected, actual, name=str(snapshot)),
98
+ )
qstd_openapi/ui.py ADDED
@@ -0,0 +1,109 @@
1
+ """Documentation pages: Redoc and Swagger UI.
2
+
3
+ A renderer only knows the URL of the specification; framework integrations
4
+ decide where pages are served. Frontend bundles are loaded from a CDN by
5
+ default; pass your own URLs to self-host them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html
11
+ import json
12
+
13
+ from collections.abc import Mapping
14
+ from typing import Any, Optional, Protocol
15
+
16
+ __all__ = ('DocsRenderer', 'Redoc', 'SwaggerUI')
17
+
18
+ REDOC_JS = 'https://cdn.jsdelivr.net/npm/redoc@2.5.4/bundles/redoc.standalone.js'
19
+ SWAGGER_UI_JS = (
20
+ 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-bundle.js'
21
+ )
22
+ SWAGGER_UI_CSS = 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui.css'
23
+
24
+
25
+ class DocsRenderer(Protocol):
26
+ def render(self, *, spec_url: str, title: str) -> str:
27
+ """A complete HTML page showing the document at ``spec_url``."""
28
+ ...
29
+
30
+
31
+ def _script_json(value: Any) -> str:
32
+ """JSON safe to embed inside a ``<script>`` element."""
33
+ return (
34
+ json.dumps(value, ensure_ascii=False)
35
+ .replace('<', '\\u003c')
36
+ .replace('>', '\\u003e')
37
+ .replace('&', '\\u0026')
38
+ )
39
+
40
+
41
+ def _page(title: str, head: str, body: str) -> str:
42
+ return (
43
+ '<!DOCTYPE html>\n'
44
+ '<html lang="en">\n'
45
+ '<head>\n'
46
+ '<meta charset="utf-8">\n'
47
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
48
+ f'<title>{html.escape(title)}</title>\n'
49
+ f'{head}'
50
+ '</head>\n'
51
+ f'<body>\n{body}</body>\n'
52
+ '</html>\n'
53
+ )
54
+
55
+
56
+ class Redoc:
57
+ """Redoc page; ``options`` are Redoc configuration options."""
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ js_url: str = REDOC_JS,
63
+ options: Optional[Mapping[str, Any]] = None,
64
+ ) -> None:
65
+ self.js_url = js_url
66
+ self.options = dict(options or {})
67
+
68
+ def render(self, *, spec_url: str, title: str) -> str:
69
+ body = (
70
+ '<div id="redoc-container"></div>\n'
71
+ f'<script src="{html.escape(self.js_url)}"></script>\n'
72
+ '<script>\n'
73
+ f'Redoc.init({_script_json(spec_url)}, {_script_json(self.options)}, '
74
+ 'document.getElementById("redoc-container"));\n'
75
+ '</script>\n'
76
+ )
77
+ return _page(title, '<style>body { margin: 0; padding: 0; }</style>\n', body)
78
+
79
+
80
+ class SwaggerUI:
81
+ """Swagger UI page; ``options`` are passed to ``SwaggerUIBundle``."""
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ js_url: str = SWAGGER_UI_JS,
87
+ css_url: str = SWAGGER_UI_CSS,
88
+ options: Optional[Mapping[str, Any]] = None,
89
+ ) -> None:
90
+ self.js_url = js_url
91
+ self.css_url = css_url
92
+ self.options = dict(options or {})
93
+
94
+ def render(self, *, spec_url: str, title: str) -> str:
95
+ options = {
96
+ 'dom_id': '#swagger-ui',
97
+ 'deepLinking': True,
98
+ **self.options,
99
+ 'url': spec_url,
100
+ }
101
+ head = f'<link rel="stylesheet" href="{html.escape(self.css_url)}">\n'
102
+ body = (
103
+ '<div id="swagger-ui"></div>\n'
104
+ f'<script src="{html.escape(self.js_url)}"></script>\n'
105
+ '<script>\n'
106
+ f'window.ui = SwaggerUIBundle({_script_json(options)});\n'
107
+ '</script>\n'
108
+ )
109
+ return _page(title, head, body)