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.
@@ -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
@@ -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
+ )