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/__init__.py +56 -0
- qstd_openapi/__main__.py +103 -0
- qstd_openapi/_compat.py +16 -0
- qstd_openapi/contrib/__init__.py +1 -0
- qstd_openapi/contrib/app_errors.py +173 -0
- qstd_openapi/core/__init__.py +56 -0
- qstd_openapi/core/document.py +1029 -0
- qstd_openapi/core/documents.py +296 -0
- qstd_openapi/core/error_responses.py +98 -0
- qstd_openapi/core/filters.py +81 -0
- qstd_openapi/core/schemas.py +554 -0
- qstd_openapi/core/sources.py +107 -0
- qstd_openapi/dialects/__init__.py +19 -0
- qstd_openapi/dialects/base.py +47 -0
- qstd_openapi/dialects/openapi30.py +312 -0
- qstd_openapi/dialects/openapi31.py +82 -0
- qstd_openapi/errors.py +117 -0
- qstd_openapi/fastapi.py +441 -0
- qstd_openapi/markers.py +57 -0
- qstd_openapi/meta/__init__.py +55 -0
- qstd_openapi/meta/merge.py +271 -0
- qstd_openapi/meta/model.py +182 -0
- qstd_openapi/meta/storage.py +112 -0
- qstd_openapi/openapi.py +656 -0
- qstd_openapi/py.typed +0 -0
- qstd_openapi/pydantic.py +494 -0
- qstd_openapi/sanic.py +343 -0
- qstd_openapi/serialization.py +63 -0
- qstd_openapi/tags.py +58 -0
- qstd_openapi/testing.py +98 -0
- qstd_openapi/ui.py +109 -0
- qstd_openapi-0.1.0.dist-info/METADATA +324 -0
- qstd_openapi-0.1.0.dist-info/RECORD +36 -0
- qstd_openapi-0.1.0.dist-info/WHEEL +5 -0
- qstd_openapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- qstd_openapi-0.1.0.dist-info/top_level.txt +1 -0
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
|
qstd_openapi/testing.py
ADDED
|
@@ -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)
|