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/fastapi.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""FastAPI integration in ``augment`` mode — **experimental**.
|
|
2
|
+
|
|
3
|
+
Covered by tests but not yet checked on a production project; the merging
|
|
4
|
+
rules may change in a minor release.
|
|
5
|
+
|
|
6
|
+
FastAPI keeps generating its document from signatures, ``response_model``
|
|
7
|
+
and dependencies; the library adds what was described with its decorators
|
|
8
|
+
(errors, extra responses, security, webhooks, raw patches...) on top.
|
|
9
|
+
|
|
10
|
+
Install with ``qstd-openapi[fastapi]``.
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
from qstd_openapi import OpenAPI
|
|
15
|
+
from qstd_openapi.fastapi import OpenAPIRouter, augment
|
|
16
|
+
|
|
17
|
+
users = OpenAPIRouter(APIRouter(prefix='/users'))
|
|
18
|
+
|
|
19
|
+
@users.post('/register', tags=['Users'], response_model=UserDTO, errors=[UserAlreadyExistsError])
|
|
20
|
+
async def register_user(body: UserRegisterInput) -> UserDTO: ...
|
|
21
|
+
|
|
22
|
+
app.include_router(users.router)
|
|
23
|
+
spec = OpenAPI(info={'title': 'Users API', 'version': '1.0.0'}, errors=[AppErrors(...)])
|
|
24
|
+
spec.include(webhooks) # routes of the app are added by augment()
|
|
25
|
+
augment(app, spec) # app.openapi() now returns the merged document
|
|
26
|
+
|
|
27
|
+
Merging rules, per operation (FastAPI's operation is the base):
|
|
28
|
+
|
|
29
|
+
- operations come from the library's build, so ``openapi.exclude`` and
|
|
30
|
+
scope filters apply; routes FastAPI hides (``include_in_schema=False``)
|
|
31
|
+
stay hidden;
|
|
32
|
+
- ``summary``, ``description``, ``operationId``, ``deprecated`` and the
|
|
33
|
+
request body set with the library win; FastAPI fills the rest;
|
|
34
|
+
- tags and security requirements are combined;
|
|
35
|
+
- parameters: FastAPI's win, the library adds the missing ones;
|
|
36
|
+
- responses: a status described with the library replaces FastAPI's for
|
|
37
|
+
that status, other FastAPI responses (e.g. 422) stay;
|
|
38
|
+
- components with the same name must match except for annotations
|
|
39
|
+
(``title``, ``default``, ``description``, examples); FastAPI's copy is kept.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import copy
|
|
45
|
+
import importlib
|
|
46
|
+
import inspect
|
|
47
|
+
|
|
48
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
49
|
+
from enum import Enum
|
|
50
|
+
from typing import Any, Callable, Generic, Optional, TypeVar, Union, cast
|
|
51
|
+
|
|
52
|
+
from typing_extensions import Unpack
|
|
53
|
+
|
|
54
|
+
from qstd_openapi import openapi
|
|
55
|
+
from qstd_openapi.core.document import OpenAPI, validate_document
|
|
56
|
+
from qstd_openapi.core.sources import HTTP_METHODS, RouteEntry, SourceEntry
|
|
57
|
+
from qstd_openapi.dialects.openapi31 import OpenAPI31
|
|
58
|
+
from qstd_openapi.errors import ComponentConflictError
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
_routing: Any = importlib.import_module('fastapi.routing')
|
|
62
|
+
_openapi_utils: Any = importlib.import_module('fastapi.openapi.utils')
|
|
63
|
+
except ImportError as exc: # pragma: no cover - extra not installed
|
|
64
|
+
raise ImportError(
|
|
65
|
+
'qstd_openapi.fastapi needs FastAPI: install qstd-openapi[fastapi]',
|
|
66
|
+
) from exc
|
|
67
|
+
|
|
68
|
+
__all__ = (
|
|
69
|
+
'FastAPIApiRouteOptions',
|
|
70
|
+
'FastAPIRouteOptions',
|
|
71
|
+
'FastAPIRoutes',
|
|
72
|
+
'OpenAPIRouter',
|
|
73
|
+
'augment',
|
|
74
|
+
'merged_document',
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
F = TypeVar('F', bound=Callable[..., Any])
|
|
78
|
+
R = TypeVar('R')
|
|
79
|
+
|
|
80
|
+
JsonObject = dict[str, Any]
|
|
81
|
+
|
|
82
|
+
_ANNOTATIONS = frozenset({'title', 'description', 'default', 'examples', 'example'})
|
|
83
|
+
_OPERATION_SCALARS = (
|
|
84
|
+
'summary',
|
|
85
|
+
'description',
|
|
86
|
+
'operationId',
|
|
87
|
+
'deprecated',
|
|
88
|
+
'requestBody',
|
|
89
|
+
)
|
|
90
|
+
_SKIPPED_METHODS = frozenset({'head', 'options'})
|
|
91
|
+
_NATIVE_OPTIONS = frozenset(
|
|
92
|
+
{'tags', 'summary', 'description', 'deprecated', 'operation_id'},
|
|
93
|
+
)
|
|
94
|
+
_DESCRIBE_OPTIONS = frozenset(openapi.DescribeOptions.__annotations__) - _NATIVE_OPTIONS
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FastAPIRoutes:
|
|
98
|
+
"""Operations of a FastAPI application (``APIRoute`` included in the schema)."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, app: Any) -> None:
|
|
101
|
+
self.app = app
|
|
102
|
+
|
|
103
|
+
def collect(self) -> Iterable[SourceEntry]:
|
|
104
|
+
entries: list[SourceEntry] = []
|
|
105
|
+
for route in _api_routes(self.app):
|
|
106
|
+
if not route.include_in_schema:
|
|
107
|
+
continue
|
|
108
|
+
for method in sorted(m.lower() for m in route.methods):
|
|
109
|
+
if method in _SKIPPED_METHODS or method not in HTTP_METHODS:
|
|
110
|
+
continue
|
|
111
|
+
entries.append(RouteEntry(route.path_format, method, route.endpoint))
|
|
112
|
+
return entries
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _api_routes(app: Any) -> Iterable[Any]:
|
|
116
|
+
"""``APIRoute``-like objects with the effective path, including nested routers.
|
|
117
|
+
|
|
118
|
+
FastAPI 0.13x+ keeps included routers lazily and exposes them through
|
|
119
|
+
``routing.iter_route_contexts``; older versions copy routes into ``app.routes``.
|
|
120
|
+
"""
|
|
121
|
+
iterate = getattr(_routing, 'iter_route_contexts', None)
|
|
122
|
+
if iterate is None:
|
|
123
|
+
return [route for route in app.routes if isinstance(route, _routing.APIRoute)]
|
|
124
|
+
return [
|
|
125
|
+
context
|
|
126
|
+
for context in iterate(app.routes)
|
|
127
|
+
if isinstance(context.original_route, _routing.APIRoute)
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _fastapi_document(app: Any) -> JsonObject:
|
|
132
|
+
"""FastAPI's own document, generated without going through ``app.openapi``."""
|
|
133
|
+
candidates: dict[str, Any] = dict( # noqa: C408 - keyword names are FastAPI's
|
|
134
|
+
title=app.title,
|
|
135
|
+
version=app.version,
|
|
136
|
+
openapi_version=app.openapi_version,
|
|
137
|
+
summary=getattr(app, 'summary', None),
|
|
138
|
+
description=app.description,
|
|
139
|
+
terms_of_service=app.terms_of_service,
|
|
140
|
+
contact=app.contact,
|
|
141
|
+
license_info=app.license_info,
|
|
142
|
+
routes=app.routes,
|
|
143
|
+
webhooks=app.webhooks.routes,
|
|
144
|
+
tags=app.openapi_tags,
|
|
145
|
+
servers=app.servers,
|
|
146
|
+
separate_input_output_schemas=getattr(
|
|
147
|
+
app,
|
|
148
|
+
'separate_input_output_schemas',
|
|
149
|
+
True,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
accepted = inspect.signature(_openapi_utils.get_openapi).parameters
|
|
153
|
+
kwargs = {k: v for k, v in candidates.items() if k in accepted}
|
|
154
|
+
return cast('JsonObject', _openapi_utils.get_openapi(**kwargs))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _strip_annotations(value: Any) -> Any:
|
|
158
|
+
if isinstance(value, dict):
|
|
159
|
+
items = cast('JsonObject', value).items()
|
|
160
|
+
return {k: _strip_annotations(v) for k, v in items if k not in _ANNOTATIONS}
|
|
161
|
+
if isinstance(value, list):
|
|
162
|
+
return [_strip_annotations(item) for item in cast('list[object]', value)]
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _merge_components(base: JsonObject, ours: JsonObject) -> None:
|
|
167
|
+
for section, entries in cast('Mapping[str, JsonObject]', ours).items():
|
|
168
|
+
target = cast('JsonObject', base.setdefault(section, {}))
|
|
169
|
+
for name, schema in entries.items():
|
|
170
|
+
existing = target.get(name)
|
|
171
|
+
if existing is None:
|
|
172
|
+
target[name] = schema
|
|
173
|
+
elif _strip_annotations(existing) != _strip_annotations(schema):
|
|
174
|
+
raise ComponentConflictError(
|
|
175
|
+
f'components/{section}/{name} differs between FastAPI and '
|
|
176
|
+
'qstd-openapi descriptions; rename one of the models',
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _merge_operation(base: JsonObject, ours: JsonObject) -> JsonObject:
|
|
181
|
+
operation = copy.deepcopy(base)
|
|
182
|
+
for key, value in ours.items():
|
|
183
|
+
if key == 'tags':
|
|
184
|
+
tags = cast('list[str]', operation.setdefault('tags', []))
|
|
185
|
+
tags.extend(tag for tag in value if tag not in tags)
|
|
186
|
+
elif key == 'security':
|
|
187
|
+
requirements = cast(
|
|
188
|
+
'list[JsonObject]',
|
|
189
|
+
operation.setdefault('security', []),
|
|
190
|
+
)
|
|
191
|
+
requirements.extend(r for r in value if r not in requirements)
|
|
192
|
+
elif key == 'parameters':
|
|
193
|
+
parameters = cast(
|
|
194
|
+
'list[JsonObject]',
|
|
195
|
+
operation.setdefault('parameters', []),
|
|
196
|
+
)
|
|
197
|
+
taken = {(p['in'], p['name']) for p in parameters}
|
|
198
|
+
parameters.extend(p for p in value if (p['in'], p['name']) not in taken)
|
|
199
|
+
elif key == 'responses':
|
|
200
|
+
responses = cast('JsonObject', operation.setdefault('responses', {}))
|
|
201
|
+
responses.update(value)
|
|
202
|
+
operation['responses'] = dict(sorted(responses.items(), key=_status_order))
|
|
203
|
+
elif key in _OPERATION_SCALARS or key not in operation:
|
|
204
|
+
operation[key] = value
|
|
205
|
+
elif isinstance(value, dict) and isinstance(operation[key], dict):
|
|
206
|
+
operation[key] = {**operation[key], **cast('JsonObject', value)}
|
|
207
|
+
else:
|
|
208
|
+
operation[key] = value
|
|
209
|
+
return operation
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _status_order(item: tuple[str, Any]) -> tuple[int, str]:
|
|
213
|
+
key = item[0]
|
|
214
|
+
return (0, key.zfill(3)) if key.isdigit() else (1, key)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _merge(base: JsonObject, ours: JsonObject) -> JsonObject:
|
|
218
|
+
document = copy.deepcopy(base)
|
|
219
|
+
base_paths = cast('JsonObject', base.get('paths', {}))
|
|
220
|
+
paths: JsonObject = {}
|
|
221
|
+
for path, operations in cast(
|
|
222
|
+
'Mapping[str, JsonObject]',
|
|
223
|
+
ours.get('paths', {}),
|
|
224
|
+
).items():
|
|
225
|
+
merged: JsonObject = {}
|
|
226
|
+
for method, operation in operations.items():
|
|
227
|
+
native = cast('JsonObject', base_paths.get(path, {})).get(method)
|
|
228
|
+
merged[method] = (
|
|
229
|
+
_merge_operation(native, operation) if native else operation
|
|
230
|
+
)
|
|
231
|
+
paths[path] = merged
|
|
232
|
+
document['paths'] = paths
|
|
233
|
+
|
|
234
|
+
components = cast('JsonObject', document.setdefault('components', {}))
|
|
235
|
+
_merge_components(components, cast('JsonObject', ours.get('components', {})))
|
|
236
|
+
if not components:
|
|
237
|
+
del document['components']
|
|
238
|
+
|
|
239
|
+
info = cast('JsonObject', document.setdefault('info', {}))
|
|
240
|
+
for key, value in cast('JsonObject', ours.get('info', {})).items():
|
|
241
|
+
info.setdefault(key, value)
|
|
242
|
+
|
|
243
|
+
for key, value in ours.items():
|
|
244
|
+
if key in ('paths', 'components', 'info'):
|
|
245
|
+
continue
|
|
246
|
+
current = document.get(key)
|
|
247
|
+
if current is None:
|
|
248
|
+
document[key] = value
|
|
249
|
+
elif key == 'tags':
|
|
250
|
+
names = {tag.get('name') for tag in cast('list[JsonObject]', current)}
|
|
251
|
+
current.extend(tag for tag in value if tag.get('name') not in names)
|
|
252
|
+
elif isinstance(current, dict) and isinstance(value, dict):
|
|
253
|
+
for name, item in cast('JsonObject', value).items():
|
|
254
|
+
cast('JsonObject', current).setdefault(name, item)
|
|
255
|
+
return document
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def merged_document(app: Any, spec: OpenAPI) -> JsonObject:
|
|
259
|
+
"""FastAPI's document of ``app`` merged with what ``spec`` describes."""
|
|
260
|
+
native = _fastapi_document(app)
|
|
261
|
+
native_schemes = cast(
|
|
262
|
+
'JsonObject',
|
|
263
|
+
cast('JsonObject', native.get('components', {})).get('securitySchemes', {}),
|
|
264
|
+
)
|
|
265
|
+
# Merge in 3.1 (what FastAPI produces), then render with the user's dialect.
|
|
266
|
+
variant = spec.derive(
|
|
267
|
+
default_response=False,
|
|
268
|
+
docstrings=False,
|
|
269
|
+
security_schemes={**native_schemes, **spec.security_schemes},
|
|
270
|
+
dialect=OpenAPI31(),
|
|
271
|
+
validate=False,
|
|
272
|
+
operation_ids=None, # FastAPI names every operation itself
|
|
273
|
+
)
|
|
274
|
+
variant.include(FastAPIRoutes(app))
|
|
275
|
+
merged = _merge(native, variant.build().document)
|
|
276
|
+
webhooks = OpenAPI31().extract_webhooks(merged)
|
|
277
|
+
document = spec.dialect.finalize(merged, webhooks)
|
|
278
|
+
if spec.validate:
|
|
279
|
+
validate_document(document)
|
|
280
|
+
return document
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def augment(app: Any, spec: OpenAPI) -> None:
|
|
284
|
+
"""Make ``app.openapi()`` return the merged document.
|
|
285
|
+
|
|
286
|
+
The result is cached in ``app.openapi_schema`` like FastAPI does; set it to
|
|
287
|
+
``None`` to rebuild after routes or descriptions change. ``spec`` should
|
|
288
|
+
not include the app's routes itself: they are added here.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def openapi_schema() -> JsonObject:
|
|
292
|
+
if app.openapi_schema is None:
|
|
293
|
+
app.openapi_schema = merged_document(app, spec)
|
|
294
|
+
return cast('JsonObject', app.openapi_schema)
|
|
295
|
+
|
|
296
|
+
app.openapi = openapi_schema
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class FastAPIRouteOptions(openapi.DocumentationOptions, total=False):
|
|
300
|
+
"""Library options plus FastAPI's own path operation options.
|
|
301
|
+
|
|
302
|
+
``tags``, ``summary``, ``description``, ``deprecated`` and ``operation_id``
|
|
303
|
+
are FastAPI's; ``responses`` is the library's ``{status: schema}`` mapping
|
|
304
|
+
and FastAPI's raw ``responses`` is ``fastapi_responses``.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
response_model: Any
|
|
308
|
+
status_code: Optional[int]
|
|
309
|
+
tags: Optional[list[Union[str, Enum]]]
|
|
310
|
+
dependencies: Optional[Sequence[Any]]
|
|
311
|
+
summary: Optional[str]
|
|
312
|
+
description: Optional[str]
|
|
313
|
+
response_description: str
|
|
314
|
+
fastapi_responses: Optional[dict[Union[int, str], dict[str, Any]]]
|
|
315
|
+
deprecated: Optional[bool]
|
|
316
|
+
operation_id: Optional[str]
|
|
317
|
+
response_model_include: Any
|
|
318
|
+
response_model_exclude: Any
|
|
319
|
+
response_model_by_alias: bool
|
|
320
|
+
response_model_exclude_unset: bool
|
|
321
|
+
response_model_exclude_defaults: bool
|
|
322
|
+
response_model_exclude_none: bool
|
|
323
|
+
include_in_schema: bool
|
|
324
|
+
response_class: Any
|
|
325
|
+
name: Optional[str]
|
|
326
|
+
callbacks: Optional[list[Any]]
|
|
327
|
+
openapi_extra: Optional[dict[str, Any]]
|
|
328
|
+
generate_unique_id_function: Callable[[Any], str]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class FastAPIApiRouteOptions(FastAPIRouteOptions, total=False):
|
|
332
|
+
"""Options of ``api_route`` and ``add_api_route``: also the HTTP methods."""
|
|
333
|
+
|
|
334
|
+
methods: Optional[list[str]]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class OpenAPIRouter(Generic[R]):
|
|
338
|
+
"""An ``APIRouter`` (or app) whose route decorators also take ``describe`` options.
|
|
339
|
+
|
|
340
|
+
``tags``, ``summary``, ``description``, ``deprecated`` and ``operation_id``
|
|
341
|
+
are FastAPI's own options and go to FastAPI. The other ``describe``
|
|
342
|
+
options (``errors``, ``responses``, ``security``, ``scope``, ...) are
|
|
343
|
+
attached to the endpoint; note that ``responses`` therefore means the
|
|
344
|
+
library's ``{status: schema}`` mapping — FastAPI's raw ``responses``
|
|
345
|
+
can be passed as ``fastapi_responses``. Everything else goes to FastAPI.
|
|
346
|
+
The wrapped object is ``.router`` (typed); other attributes are also
|
|
347
|
+
delegated to it, untyped.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def __init__(self, router: R) -> None:
|
|
351
|
+
self.router: R = router
|
|
352
|
+
|
|
353
|
+
def __getattr__(self, name: str) -> Any:
|
|
354
|
+
return getattr(self.router, name)
|
|
355
|
+
|
|
356
|
+
def api_route(
|
|
357
|
+
self,
|
|
358
|
+
path: str,
|
|
359
|
+
/,
|
|
360
|
+
**options: Unpack[FastAPIApiRouteOptions],
|
|
361
|
+
) -> Callable[[F], F]:
|
|
362
|
+
return self._register('api_route', path, options)
|
|
363
|
+
|
|
364
|
+
def get(
|
|
365
|
+
self,
|
|
366
|
+
path: str,
|
|
367
|
+
/,
|
|
368
|
+
**options: Unpack[FastAPIRouteOptions],
|
|
369
|
+
) -> Callable[[F], F]:
|
|
370
|
+
return self._register('get', path, options)
|
|
371
|
+
|
|
372
|
+
def post(
|
|
373
|
+
self,
|
|
374
|
+
path: str,
|
|
375
|
+
/,
|
|
376
|
+
**options: Unpack[FastAPIRouteOptions],
|
|
377
|
+
) -> Callable[[F], F]:
|
|
378
|
+
return self._register('post', path, options)
|
|
379
|
+
|
|
380
|
+
def put(
|
|
381
|
+
self,
|
|
382
|
+
path: str,
|
|
383
|
+
/,
|
|
384
|
+
**options: Unpack[FastAPIRouteOptions],
|
|
385
|
+
) -> Callable[[F], F]:
|
|
386
|
+
return self._register('put', path, options)
|
|
387
|
+
|
|
388
|
+
def patch(
|
|
389
|
+
self,
|
|
390
|
+
path: str,
|
|
391
|
+
/,
|
|
392
|
+
**options: Unpack[FastAPIRouteOptions],
|
|
393
|
+
) -> Callable[[F], F]:
|
|
394
|
+
return self._register('patch', path, options)
|
|
395
|
+
|
|
396
|
+
def delete(
|
|
397
|
+
self,
|
|
398
|
+
path: str,
|
|
399
|
+
/,
|
|
400
|
+
**options: Unpack[FastAPIRouteOptions],
|
|
401
|
+
) -> Callable[[F], F]:
|
|
402
|
+
return self._register('delete', path, options)
|
|
403
|
+
|
|
404
|
+
def add_api_route(
|
|
405
|
+
self,
|
|
406
|
+
path: str,
|
|
407
|
+
endpoint: F,
|
|
408
|
+
/,
|
|
409
|
+
**options: Unpack[FastAPIApiRouteOptions],
|
|
410
|
+
) -> F:
|
|
411
|
+
described, native = _split(options)
|
|
412
|
+
if described:
|
|
413
|
+
openapi.attach(endpoint, **described)
|
|
414
|
+
router: Any = self.router
|
|
415
|
+
router.add_api_route(path, endpoint, **native)
|
|
416
|
+
return endpoint
|
|
417
|
+
|
|
418
|
+
def _register(
|
|
419
|
+
self,
|
|
420
|
+
method: str,
|
|
421
|
+
path: str,
|
|
422
|
+
options: Mapping[str, Any],
|
|
423
|
+
) -> Callable[[F], F]:
|
|
424
|
+
described, native = _split(options)
|
|
425
|
+
register = getattr(self.router, method)(path, **native)
|
|
426
|
+
|
|
427
|
+
def decorate(endpoint: F) -> F:
|
|
428
|
+
if described:
|
|
429
|
+
openapi.attach(endpoint, **described)
|
|
430
|
+
register(endpoint)
|
|
431
|
+
return endpoint
|
|
432
|
+
|
|
433
|
+
return decorate
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _split(options: Mapping[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
437
|
+
described = {k: v for k, v in options.items() if k in _DESCRIBE_OPTIONS}
|
|
438
|
+
native = {k: v for k, v in options.items() if k not in _DESCRIBE_OPTIONS}
|
|
439
|
+
if 'fastapi_responses' in native:
|
|
440
|
+
native['responses'] = native.pop('fastapi_responses')
|
|
441
|
+
return described, native
|
qstd_openapi/markers.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Version-neutral schema markers.
|
|
2
|
+
|
|
3
|
+
They describe *what* the payload is; the dialect decides how a particular
|
|
4
|
+
OpenAPI version spells it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class File:
|
|
16
|
+
"""Binary content (an uploaded or downloaded file)."""
|
|
17
|
+
|
|
18
|
+
description: Optional[str] = 'File'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class FileList:
|
|
23
|
+
"""Several files under one form field."""
|
|
24
|
+
|
|
25
|
+
max_items: Optional[int] = None
|
|
26
|
+
description: Optional[str] = 'File'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class FormFields:
|
|
31
|
+
"""A form body (``multipart/form-data`` and the like)."""
|
|
32
|
+
|
|
33
|
+
fields: tuple[tuple[str, Any], ...]
|
|
34
|
+
required: tuple[str, ...] = ()
|
|
35
|
+
description: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def of(
|
|
39
|
+
cls,
|
|
40
|
+
fields: Mapping[str, Any],
|
|
41
|
+
required: tuple[str, ...] = (),
|
|
42
|
+
description: Optional[str] = None,
|
|
43
|
+
) -> FormFields:
|
|
44
|
+
return cls(tuple(fields.items()), required, description)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Ref:
|
|
49
|
+
"""A schema reference inside a raw JSON Schema dict.
|
|
50
|
+
|
|
51
|
+
``{'type': 'array', 'items': Ref(UserDTO)}`` is resolved by the schema
|
|
52
|
+
providers like any other schema reference. ``mode`` overrides the mode of
|
|
53
|
+
the surrounding usage (``'validation'`` or ``'serialization'``).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
target: Any
|
|
57
|
+
mode: Optional[str] = None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from qstd_openapi.meta.merge import (
|
|
2
|
+
ErrorOnConflict,
|
|
3
|
+
LastWins,
|
|
4
|
+
Located,
|
|
5
|
+
ScalarConflicts,
|
|
6
|
+
merge_contributions,
|
|
7
|
+
read_operation,
|
|
8
|
+
)
|
|
9
|
+
from qstd_openapi.meta.model import (
|
|
10
|
+
BodyPart,
|
|
11
|
+
Content,
|
|
12
|
+
Contribution,
|
|
13
|
+
ErrorRef,
|
|
14
|
+
Example,
|
|
15
|
+
OperationMeta,
|
|
16
|
+
OperationPatch,
|
|
17
|
+
Origin,
|
|
18
|
+
Parameter,
|
|
19
|
+
ParameterLocation,
|
|
20
|
+
ParameterModel,
|
|
21
|
+
Response,
|
|
22
|
+
ResponseHeader,
|
|
23
|
+
ResponsePart,
|
|
24
|
+
Security,
|
|
25
|
+
StatusCode,
|
|
26
|
+
Webhook,
|
|
27
|
+
)
|
|
28
|
+
from qstd_openapi.meta.storage import read_contributions
|
|
29
|
+
|
|
30
|
+
__all__ = (
|
|
31
|
+
'BodyPart',
|
|
32
|
+
'Content',
|
|
33
|
+
'Contribution',
|
|
34
|
+
'ErrorOnConflict',
|
|
35
|
+
'ErrorRef',
|
|
36
|
+
'Example',
|
|
37
|
+
'LastWins',
|
|
38
|
+
'Located',
|
|
39
|
+
'OperationMeta',
|
|
40
|
+
'OperationPatch',
|
|
41
|
+
'Origin',
|
|
42
|
+
'Parameter',
|
|
43
|
+
'ParameterLocation',
|
|
44
|
+
'ParameterModel',
|
|
45
|
+
'Response',
|
|
46
|
+
'ResponseHeader',
|
|
47
|
+
'ResponsePart',
|
|
48
|
+
'ScalarConflicts',
|
|
49
|
+
'Security',
|
|
50
|
+
'StatusCode',
|
|
51
|
+
'Webhook',
|
|
52
|
+
'merge_contributions',
|
|
53
|
+
'read_contributions',
|
|
54
|
+
'read_operation',
|
|
55
|
+
)
|