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
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
"""Building an OpenAPI document from operation sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import http
|
|
7
|
+
import importlib.util
|
|
8
|
+
import inspect
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from typing import Any, Callable, Literal, Optional, Union, cast
|
|
15
|
+
|
|
16
|
+
from qstd_openapi.core.documents import Conflict, DocumentContribution
|
|
17
|
+
from qstd_openapi.core.error_responses import ErrorProvider, ErrorSchemas
|
|
18
|
+
from qstd_openapi.core.filters import ScopeFilter, TagRule
|
|
19
|
+
from qstd_openapi.core.schemas import (
|
|
20
|
+
BuiltinSchemas,
|
|
21
|
+
Components,
|
|
22
|
+
JsonSchema,
|
|
23
|
+
SchemaMode,
|
|
24
|
+
SchemaProvider,
|
|
25
|
+
SchemaResolver,
|
|
26
|
+
TypeOverrides,
|
|
27
|
+
TypeOverrideValue,
|
|
28
|
+
)
|
|
29
|
+
from qstd_openapi.core.sources import (
|
|
30
|
+
HTTP_METHODS,
|
|
31
|
+
OperationSource,
|
|
32
|
+
RouteEntry,
|
|
33
|
+
SourceEntry,
|
|
34
|
+
WebhookEntry,
|
|
35
|
+
)
|
|
36
|
+
from qstd_openapi.dialects.base import OpenAPIDialect
|
|
37
|
+
from qstd_openapi.dialects.openapi31 import OpenAPI31
|
|
38
|
+
from qstd_openapi.errors import (
|
|
39
|
+
BuildError,
|
|
40
|
+
ComponentConflictError,
|
|
41
|
+
DocumentConflictError,
|
|
42
|
+
DuplicateOperationIdError,
|
|
43
|
+
InvalidDocumentError,
|
|
44
|
+
OperationConflictError,
|
|
45
|
+
UnknownSecuritySchemeError,
|
|
46
|
+
)
|
|
47
|
+
from qstd_openapi.markers import File, FileList
|
|
48
|
+
from qstd_openapi.meta.merge import (
|
|
49
|
+
ScalarConflicts,
|
|
50
|
+
get_scalar_strategy,
|
|
51
|
+
merge_contributions,
|
|
52
|
+
)
|
|
53
|
+
from qstd_openapi.meta.model import (
|
|
54
|
+
Examples,
|
|
55
|
+
OperationMeta,
|
|
56
|
+
Parameter,
|
|
57
|
+
StatusCode,
|
|
58
|
+
)
|
|
59
|
+
from qstd_openapi.meta.storage import describe_owner, read_contributions
|
|
60
|
+
|
|
61
|
+
_PATH_PARAMETER = re.compile(r'{([^{}]+)}')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Diagnostic:
|
|
66
|
+
code: str
|
|
67
|
+
level: str
|
|
68
|
+
"""``'info'`` or ``'warning'``; errors are raised as :class:`BuildError`."""
|
|
69
|
+
message: str
|
|
70
|
+
path: tuple[str, ...] = ()
|
|
71
|
+
origins: tuple[str, ...] = ()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class BuildResult:
|
|
76
|
+
document: dict[str, Any]
|
|
77
|
+
"""The built document; treat it as read-only, it is cached by :class:`OpenAPI`."""
|
|
78
|
+
diagnostics: tuple[Diagnostic, ...] = field(default=())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
OperationIdStrategy = Callable[[SourceEntry, OperationMeta], Optional[str]]
|
|
82
|
+
"""``(entry, merged metadata) -> operationId`` or ``None`` to leave it out."""
|
|
83
|
+
|
|
84
|
+
OperationIds = Union[Literal['route_name', 'function'], OperationIdStrategy, None]
|
|
85
|
+
"""How operations without an explicit ``operation_id`` are named:
|
|
86
|
+
|
|
87
|
+
- ``'route_name'`` (default): the route's name when the source knows it
|
|
88
|
+
(Sanic: ``<blueprint>_<handler>``), otherwise the function name;
|
|
89
|
+
- ``'function'``: the function name;
|
|
90
|
+
- a callable ``(entry, meta) -> str | None``;
|
|
91
|
+
- ``None``: no generated ``operationId``.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _function_name(entry: SourceEntry, _meta: OperationMeta) -> Optional[str]:
|
|
96
|
+
name = getattr(entry.handler, '__name__', None)
|
|
97
|
+
return name if isinstance(name, str) else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _route_name(entry: SourceEntry, meta: OperationMeta) -> Optional[str]:
|
|
101
|
+
if isinstance(entry, RouteEntry) and entry.name:
|
|
102
|
+
return entry.name
|
|
103
|
+
return _function_name(entry, meta)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_OPERATION_ID_STRATEGIES: Mapping[str, OperationIdStrategy] = MappingProxyType(
|
|
107
|
+
{'route_name': _route_name, 'function': _function_name},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _operation_id_strategy(value: OperationIds) -> Optional[OperationIdStrategy]:
|
|
112
|
+
if value is None or callable(value):
|
|
113
|
+
return value
|
|
114
|
+
try:
|
|
115
|
+
return _OPERATION_ID_STRATEGIES[value]
|
|
116
|
+
except KeyError:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f'Unknown operation_ids {value!r}: use '
|
|
119
|
+
f'{", ".join(map(repr, _OPERATION_ID_STRATEGIES))}, a function or None',
|
|
120
|
+
) from None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _default_schema_providers() -> tuple[SchemaProvider, ...]:
|
|
124
|
+
if importlib.util.find_spec('pydantic') is None:
|
|
125
|
+
return ()
|
|
126
|
+
from qstd_openapi.pydantic import PydanticSchemas
|
|
127
|
+
|
|
128
|
+
return (PydanticSchemas(),)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class OpenAPI:
|
|
132
|
+
"""One OpenAPI document: configuration, included sources and a cached result.
|
|
133
|
+
|
|
134
|
+
Instances are independent; several documents (for example one per API
|
|
135
|
+
audience, filtered by ``scopes``) can be built from the same handlers.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
info: Mapping[str, Any],
|
|
142
|
+
servers: Sequence[Mapping[str, Any]] = (),
|
|
143
|
+
security_schemes: Optional[Mapping[str, Mapping[str, Any]]] = None,
|
|
144
|
+
tags: Sequence[Mapping[str, Any]] = (),
|
|
145
|
+
extensions: Optional[Mapping[str, Any]] = None,
|
|
146
|
+
schemas: Optional[Sequence[SchemaProvider]] = None,
|
|
147
|
+
errors: Sequence[ErrorProvider] = (),
|
|
148
|
+
dialect: Optional[OpenAPIDialect] = None,
|
|
149
|
+
scalar_conflicts: ScalarConflicts = 'error',
|
|
150
|
+
scopes: Optional[ScopeFilter] = None,
|
|
151
|
+
tag_rules: Sequence[TagRule] = (),
|
|
152
|
+
docstrings: bool = True,
|
|
153
|
+
default_response: bool = True,
|
|
154
|
+
type_overrides: Optional[Mapping[Any, TypeOverrideValue]] = None,
|
|
155
|
+
validate: bool = False,
|
|
156
|
+
operation_ids: OperationIds = 'route_name',
|
|
157
|
+
) -> None:
|
|
158
|
+
missing = [key for key in ('title', 'version') if not info.get(key)]
|
|
159
|
+
if missing:
|
|
160
|
+
raise ValueError(f'info must contain {" and ".join(missing)}')
|
|
161
|
+
extensions = dict(extensions or {})
|
|
162
|
+
bad = [key for key in extensions if not key.startswith('x-')]
|
|
163
|
+
if bad:
|
|
164
|
+
raise ValueError(f'Extension keys must start with "x-": {", ".join(bad)}')
|
|
165
|
+
self.info = copy.deepcopy(dict(info))
|
|
166
|
+
self.servers = copy.deepcopy([dict(server) for server in servers])
|
|
167
|
+
self.security_schemes = copy.deepcopy(dict(security_schemes or {}))
|
|
168
|
+
self.tags = copy.deepcopy([dict(tag) for tag in tags])
|
|
169
|
+
self.extensions = copy.deepcopy(extensions)
|
|
170
|
+
self.schema_providers = (
|
|
171
|
+
tuple(schemas) if schemas is not None else _default_schema_providers()
|
|
172
|
+
)
|
|
173
|
+
self.error_providers = tuple(errors)
|
|
174
|
+
self.dialect: OpenAPIDialect = dialect or OpenAPI31()
|
|
175
|
+
get_scalar_strategy(scalar_conflicts) # fail early on an unknown mode
|
|
176
|
+
self.scalar_conflicts: ScalarConflicts = scalar_conflicts
|
|
177
|
+
self.scopes = scopes or ScopeFilter()
|
|
178
|
+
self.tag_rules = tuple(tag_rules)
|
|
179
|
+
self.docstrings = docstrings
|
|
180
|
+
self.default_response = default_response
|
|
181
|
+
self.type_overrides = TypeOverrides(type_overrides)
|
|
182
|
+
self.validate = validate
|
|
183
|
+
self.operation_ids: OperationIds = operation_ids
|
|
184
|
+
_operation_id_strategy(operation_ids) # fail early on an unknown name
|
|
185
|
+
self._sources: list[OperationSource] = []
|
|
186
|
+
self._cache: Optional[BuildResult] = None
|
|
187
|
+
|
|
188
|
+
def include(self, *sources: OperationSource, check: bool = False) -> None:
|
|
189
|
+
"""Add sources (routes, webhook sets, documents); invalidates the cache.
|
|
190
|
+
|
|
191
|
+
With ``check=True`` the document is built right away; if that fails the
|
|
192
|
+
sources are not added and the error is raised, so a bad inclusion
|
|
193
|
+
never leaves the instance half-changed.
|
|
194
|
+
"""
|
|
195
|
+
previous = list(self._sources)
|
|
196
|
+
self._sources.extend(sources)
|
|
197
|
+
self._cache = None
|
|
198
|
+
if check:
|
|
199
|
+
try:
|
|
200
|
+
self.build()
|
|
201
|
+
except Exception:
|
|
202
|
+
self._sources = previous
|
|
203
|
+
self._cache = None
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def sources(self) -> tuple[OperationSource, ...]:
|
|
208
|
+
return tuple(self._sources)
|
|
209
|
+
|
|
210
|
+
def derive(self, **changes: Any) -> OpenAPI:
|
|
211
|
+
"""A copy with some settings changed; sources are copied, the cache is not.
|
|
212
|
+
|
|
213
|
+
``changes`` are attribute names of this class (``default_response``,
|
|
214
|
+
``docstrings``, ``security_schemes``, ...). Used by framework
|
|
215
|
+
integrations that build a variant of the user's document.
|
|
216
|
+
"""
|
|
217
|
+
unknown = [
|
|
218
|
+
name for name in changes if name.startswith('_') or not hasattr(self, name)
|
|
219
|
+
]
|
|
220
|
+
if unknown:
|
|
221
|
+
raise TypeError(f'Unknown OpenAPI settings: {", ".join(unknown)}')
|
|
222
|
+
clone = copy.copy(self)
|
|
223
|
+
for name, value in changes.items():
|
|
224
|
+
setattr(clone, name, value)
|
|
225
|
+
clone._sources = list(self._sources)
|
|
226
|
+
clone._cache = None
|
|
227
|
+
return clone
|
|
228
|
+
|
|
229
|
+
def invalidate(self) -> None:
|
|
230
|
+
"""Drop the cached document (sources may have changed underneath)."""
|
|
231
|
+
self._cache = None
|
|
232
|
+
|
|
233
|
+
def build(self) -> BuildResult:
|
|
234
|
+
"""Build (or return the cached) document.
|
|
235
|
+
|
|
236
|
+
With ``validate=True`` the document is checked by
|
|
237
|
+
``openapi-spec-validator`` (the ``validate`` extra) before it is
|
|
238
|
+
cached; an invalid one raises :class:`InvalidDocumentError`.
|
|
239
|
+
"""
|
|
240
|
+
if self._cache is None:
|
|
241
|
+
result = _Builder(self, tuple(self._sources)).build()
|
|
242
|
+
if self.validate:
|
|
243
|
+
validate_document(result.document)
|
|
244
|
+
self._cache = result
|
|
245
|
+
return self._cache
|
|
246
|
+
|
|
247
|
+
def build_dict(self) -> dict[str, Any]:
|
|
248
|
+
"""A copy of the built document that may be modified freely."""
|
|
249
|
+
return copy.deepcopy(self.build().document)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class _GeneratedId:
|
|
254
|
+
base: str
|
|
255
|
+
location: str
|
|
256
|
+
"""Path, or the webhook name."""
|
|
257
|
+
method: str
|
|
258
|
+
origin: object
|
|
259
|
+
"""The route name if the source knows it, else the handler's identity."""
|
|
260
|
+
operation: JsonSchema
|
|
261
|
+
owner: str
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass
|
|
265
|
+
class _ModelParameters:
|
|
266
|
+
parameters: list[JsonSchema]
|
|
267
|
+
location: str
|
|
268
|
+
slot: JsonSchema
|
|
269
|
+
taken: set[tuple[str, str]]
|
|
270
|
+
owner: str
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class _ResponseDraft:
|
|
275
|
+
description: Optional[str] = None
|
|
276
|
+
error_description: Optional[str] = None
|
|
277
|
+
content: dict[str, list[tuple[Any, bool]]] = field(
|
|
278
|
+
default_factory=lambda: dict[str, list[tuple[Any, bool]]](),
|
|
279
|
+
)
|
|
280
|
+
headers: JsonSchema = field(default_factory=lambda: JsonSchema())
|
|
281
|
+
examples: dict[str, Examples] = field(
|
|
282
|
+
default_factory=lambda: dict[str, Examples](),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _render_examples(examples: Examples) -> JsonSchema:
|
|
287
|
+
rendered: JsonSchema = {}
|
|
288
|
+
for name, example in examples:
|
|
289
|
+
item: JsonSchema = {}
|
|
290
|
+
if example.summary:
|
|
291
|
+
item['summary'] = example.summary
|
|
292
|
+
if example.description:
|
|
293
|
+
item['description'] = example.description
|
|
294
|
+
item['value'] = copy.deepcopy(example.value)
|
|
295
|
+
rendered[name] = item
|
|
296
|
+
return rendered
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _status_key(status: StatusCode) -> str:
|
|
300
|
+
return str(status)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _status_order(key: str) -> tuple[int, str]:
|
|
304
|
+
return (0, key.zfill(3)) if key.isdigit() else (1, key)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _default_description(key: str) -> str:
|
|
308
|
+
if key.isdigit():
|
|
309
|
+
try:
|
|
310
|
+
return http.HTTPStatus(int(key)).phrase
|
|
311
|
+
except ValueError:
|
|
312
|
+
pass
|
|
313
|
+
if key == 'default':
|
|
314
|
+
return 'Default response'
|
|
315
|
+
return f'{key} response'
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _parameter_key(location: str, name: str) -> tuple[str, str]:
|
|
319
|
+
return (location, name.lower() if location == 'header' else name)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _deep_merge(target: JsonSchema, patch: Mapping[str, Any]) -> None:
|
|
323
|
+
for key, value in patch.items():
|
|
324
|
+
current = target.get(key)
|
|
325
|
+
if isinstance(current, dict) and isinstance(value, Mapping):
|
|
326
|
+
_deep_merge(cast('JsonSchema', current), cast('Mapping[str, Any]', value))
|
|
327
|
+
else:
|
|
328
|
+
target[key] = copy.deepcopy(value)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class _Builder:
|
|
332
|
+
def __init__(self, config: OpenAPI, sources: tuple[OperationSource, ...]) -> None:
|
|
333
|
+
self.config = config
|
|
334
|
+
self.sources = sources
|
|
335
|
+
self.errors = ErrorSchemas(config.error_providers)
|
|
336
|
+
self.components = Components()
|
|
337
|
+
self.resolver = SchemaResolver(
|
|
338
|
+
[*config.schema_providers, self.errors, BuiltinSchemas()],
|
|
339
|
+
config.dialect,
|
|
340
|
+
self.components,
|
|
341
|
+
type_overrides=config.type_overrides,
|
|
342
|
+
)
|
|
343
|
+
self.paths: dict[str, dict[str, JsonSchema]] = {}
|
|
344
|
+
self.webhooks: dict[str, dict[str, JsonSchema]] = {}
|
|
345
|
+
self.owners: dict[tuple[str, str, str], str] = {}
|
|
346
|
+
self.operation_ids: dict[str, str] = {}
|
|
347
|
+
self.generated_ids: list[_GeneratedId] = []
|
|
348
|
+
self.used_tags: list[str] = []
|
|
349
|
+
self.expansions: list[_ModelParameters] = []
|
|
350
|
+
self.diagnostics: list[Diagnostic] = []
|
|
351
|
+
self.raw_components: dict[str, dict[str, tuple[JsonSchema, str]]] = {}
|
|
352
|
+
self.raw_tags: dict[str, tuple[JsonSchema, str]] = {}
|
|
353
|
+
self.raw_extensions: dict[str, tuple[Any, str]] = {}
|
|
354
|
+
|
|
355
|
+
# --- entry points ---------------------------------------------------
|
|
356
|
+
|
|
357
|
+
def build(self) -> BuildResult:
|
|
358
|
+
contributions: list[DocumentContribution] = []
|
|
359
|
+
for source in self.sources:
|
|
360
|
+
for entry in source.collect():
|
|
361
|
+
self._add(entry)
|
|
362
|
+
contribute = getattr(source, 'contribution', None)
|
|
363
|
+
if callable(contribute):
|
|
364
|
+
contributions.append(
|
|
365
|
+
cast('DocumentContribution', contribute(self.config.dialect)),
|
|
366
|
+
)
|
|
367
|
+
self._assign_operation_ids()
|
|
368
|
+
self.resolver.finish()
|
|
369
|
+
self.resolver.fill(self.paths)
|
|
370
|
+
self.resolver.fill(self.webhooks)
|
|
371
|
+
for expansion in self.expansions:
|
|
372
|
+
self.resolver.fill(expansion.slot)
|
|
373
|
+
self._expand(expansion)
|
|
374
|
+
self._prune_components()
|
|
375
|
+
for contribution in contributions:
|
|
376
|
+
self._merge_document(contribution)
|
|
377
|
+
document = self.config.dialect.finalize(self._document(), self._webhooks())
|
|
378
|
+
return BuildResult(document, tuple(self.diagnostics))
|
|
379
|
+
|
|
380
|
+
def _add(self, entry: SourceEntry) -> None:
|
|
381
|
+
owner = describe_owner(entry.handler)
|
|
382
|
+
meta = merge_contributions(
|
|
383
|
+
read_contributions(entry.handler),
|
|
384
|
+
self.config.scalar_conflicts,
|
|
385
|
+
)
|
|
386
|
+
if meta.exclude or not self.config.scopes.allows(meta.scopes):
|
|
387
|
+
return
|
|
388
|
+
if isinstance(entry, WebhookEntry):
|
|
389
|
+
if meta.webhook is None:
|
|
390
|
+
raise BuildError(
|
|
391
|
+
f'{owner} is included as a webhook but has no openapi.webhook(...)',
|
|
392
|
+
)
|
|
393
|
+
method = meta.webhook.method
|
|
394
|
+
operation = self._operation(entry.handler, meta, None, method, (), ())
|
|
395
|
+
self._generate_operation_id(
|
|
396
|
+
entry,
|
|
397
|
+
meta,
|
|
398
|
+
meta.webhook.name,
|
|
399
|
+
method,
|
|
400
|
+
operation,
|
|
401
|
+
owner,
|
|
402
|
+
)
|
|
403
|
+
self._place(
|
|
404
|
+
self.webhooks,
|
|
405
|
+
'webhook',
|
|
406
|
+
meta.webhook.name,
|
|
407
|
+
method,
|
|
408
|
+
operation,
|
|
409
|
+
owner,
|
|
410
|
+
)
|
|
411
|
+
return
|
|
412
|
+
assert isinstance(entry, RouteEntry)
|
|
413
|
+
operation = self._operation(
|
|
414
|
+
entry.handler,
|
|
415
|
+
meta,
|
|
416
|
+
entry.path,
|
|
417
|
+
entry.method,
|
|
418
|
+
entry.tags,
|
|
419
|
+
entry.parameters,
|
|
420
|
+
)
|
|
421
|
+
self._generate_operation_id(
|
|
422
|
+
entry,
|
|
423
|
+
meta,
|
|
424
|
+
entry.path,
|
|
425
|
+
entry.method,
|
|
426
|
+
operation,
|
|
427
|
+
owner,
|
|
428
|
+
)
|
|
429
|
+
self._place(self.paths, 'path', entry.path, entry.method, operation, owner)
|
|
430
|
+
|
|
431
|
+
def _generate_operation_id(
|
|
432
|
+
self,
|
|
433
|
+
entry: SourceEntry,
|
|
434
|
+
meta: OperationMeta,
|
|
435
|
+
location: str,
|
|
436
|
+
method: str,
|
|
437
|
+
operation: JsonSchema,
|
|
438
|
+
owner: str,
|
|
439
|
+
) -> None:
|
|
440
|
+
strategy = _operation_id_strategy(self.config.operation_ids)
|
|
441
|
+
if strategy is None or 'operationId' in operation:
|
|
442
|
+
return
|
|
443
|
+
base = strategy(entry, meta)
|
|
444
|
+
if base:
|
|
445
|
+
origin: object = (
|
|
446
|
+
entry.name
|
|
447
|
+
if isinstance(entry, RouteEntry) and entry.name
|
|
448
|
+
else id(entry.handler)
|
|
449
|
+
)
|
|
450
|
+
self.generated_ids.append(
|
|
451
|
+
_GeneratedId(base, location, method, origin, operation, owner),
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def _assign_operation_ids(self) -> None:
|
|
455
|
+
"""Name operations after explicit ids are known, so those always win.
|
|
456
|
+
|
|
457
|
+
A name shared by several operations of one route (or one handler when
|
|
458
|
+
the source has no route names) gets a ``_<method>`` suffix, and
|
|
459
|
+
``_<method>_<path>`` when one handler serves a method on several paths.
|
|
460
|
+
Different routes or handlers with one name are an error: only the
|
|
461
|
+
project knows which name each should get.
|
|
462
|
+
"""
|
|
463
|
+
groups: dict[str, list[_GeneratedId]] = {}
|
|
464
|
+
for item in self.generated_ids:
|
|
465
|
+
groups.setdefault(item.base, []).append(item)
|
|
466
|
+
for base, items in groups.items():
|
|
467
|
+
if len({item.origin for item in items}) > 1:
|
|
468
|
+
first, second = items[0], items[1]
|
|
469
|
+
raise DuplicateOperationIdError(
|
|
470
|
+
f'Generated operationId {base!r} is used by {first.owner} and '
|
|
471
|
+
f'{second.owner}; set operation_id(...) on one of them or change '
|
|
472
|
+
'OpenAPI(operation_ids=...)',
|
|
473
|
+
)
|
|
474
|
+
methods = [item.method for item in items]
|
|
475
|
+
distinct = len(set(methods)) == len(methods)
|
|
476
|
+
for item in items:
|
|
477
|
+
if len(items) == 1:
|
|
478
|
+
operation_id = base
|
|
479
|
+
elif distinct:
|
|
480
|
+
operation_id = f'{base}_{item.method}'
|
|
481
|
+
else:
|
|
482
|
+
slug = re.sub(r'[^0-9A-Za-z]+', '_', item.location).strip('_')
|
|
483
|
+
operation_id = f'{base}_{item.method}_{slug}'
|
|
484
|
+
previous = self.operation_ids.get(operation_id)
|
|
485
|
+
if previous is not None:
|
|
486
|
+
raise DuplicateOperationIdError(
|
|
487
|
+
f'Generated operationId {operation_id!r} is used by '
|
|
488
|
+
f'{previous} and {item.owner}; set operation_id(...) on one '
|
|
489
|
+
'of them or change OpenAPI(operation_ids=...)',
|
|
490
|
+
)
|
|
491
|
+
self.operation_ids[operation_id] = item.owner
|
|
492
|
+
item.operation['operationId'] = operation_id
|
|
493
|
+
|
|
494
|
+
def _place(
|
|
495
|
+
self,
|
|
496
|
+
target: dict[str, dict[str, JsonSchema]],
|
|
497
|
+
kind: str,
|
|
498
|
+
name: str,
|
|
499
|
+
method: str,
|
|
500
|
+
operation: JsonSchema,
|
|
501
|
+
owner: str,
|
|
502
|
+
) -> None:
|
|
503
|
+
key = (kind, name, method)
|
|
504
|
+
if key in self.owners:
|
|
505
|
+
raise OperationConflictError(
|
|
506
|
+
f'{method.upper()} {kind} {name!r} is served by both '
|
|
507
|
+
f'{self.owners[key]} and {owner}',
|
|
508
|
+
)
|
|
509
|
+
self.owners[key] = owner
|
|
510
|
+
target.setdefault(name, {})[method] = operation
|
|
511
|
+
|
|
512
|
+
# --- operation ------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
def _operation(
|
|
515
|
+
self,
|
|
516
|
+
handler: Any,
|
|
517
|
+
meta: OperationMeta,
|
|
518
|
+
path: Optional[str],
|
|
519
|
+
method: str,
|
|
520
|
+
default_tags: tuple[str, ...],
|
|
521
|
+
default_parameters: tuple[Parameter, ...],
|
|
522
|
+
) -> JsonSchema:
|
|
523
|
+
owner = describe_owner(handler)
|
|
524
|
+
operation: JsonSchema = {}
|
|
525
|
+
|
|
526
|
+
tags = list(meta.tags or default_tags)
|
|
527
|
+
for rule in self.config.tag_rules:
|
|
528
|
+
tags.extend(tag for tag in rule(path, method, meta) if tag not in tags)
|
|
529
|
+
if tags:
|
|
530
|
+
operation['tags'] = tags
|
|
531
|
+
self.used_tags.extend(tag for tag in tags if tag not in self.used_tags)
|
|
532
|
+
|
|
533
|
+
summary, description = self._texts(handler, meta)
|
|
534
|
+
if summary:
|
|
535
|
+
operation['summary'] = summary
|
|
536
|
+
if description:
|
|
537
|
+
operation['description'] = description
|
|
538
|
+
|
|
539
|
+
if meta.operation_id:
|
|
540
|
+
previous = self.operation_ids.get(meta.operation_id)
|
|
541
|
+
if previous is not None:
|
|
542
|
+
raise DuplicateOperationIdError(
|
|
543
|
+
f'operationId {meta.operation_id!r} is used by {previous} and {owner}',
|
|
544
|
+
)
|
|
545
|
+
self.operation_ids[meta.operation_id] = owner
|
|
546
|
+
operation['operationId'] = meta.operation_id
|
|
547
|
+
|
|
548
|
+
parameters = self._parameters(meta, path, default_parameters, owner)
|
|
549
|
+
if parameters is not None:
|
|
550
|
+
operation['parameters'] = parameters
|
|
551
|
+
|
|
552
|
+
if meta.body:
|
|
553
|
+
# A webhook body is sent by us, so it is described as serialized.
|
|
554
|
+
mode: SchemaMode = 'serialization' if path is None else 'validation'
|
|
555
|
+
body_content: JsonSchema = {}
|
|
556
|
+
for content in meta.body:
|
|
557
|
+
media: JsonSchema = {}
|
|
558
|
+
if content.schemas:
|
|
559
|
+
media['schema'] = self.resolver.content_schema(
|
|
560
|
+
content.schemas,
|
|
561
|
+
content.media_type,
|
|
562
|
+
mode,
|
|
563
|
+
)
|
|
564
|
+
if content.examples:
|
|
565
|
+
media['examples'] = _render_examples(content.examples)
|
|
566
|
+
body_content[content.media_type] = media
|
|
567
|
+
operation['requestBody'] = {'content': body_content, 'required': True}
|
|
568
|
+
|
|
569
|
+
responses = self._responses(meta, owner)
|
|
570
|
+
if responses:
|
|
571
|
+
operation['responses'] = responses
|
|
572
|
+
|
|
573
|
+
security = self._security(meta, owner)
|
|
574
|
+
if security:
|
|
575
|
+
operation['security'] = security
|
|
576
|
+
if meta.deprecated:
|
|
577
|
+
operation['deprecated'] = True
|
|
578
|
+
for extra in meta.extra:
|
|
579
|
+
_deep_merge(operation, extra)
|
|
580
|
+
return operation
|
|
581
|
+
|
|
582
|
+
def _texts(
|
|
583
|
+
self,
|
|
584
|
+
handler: Any,
|
|
585
|
+
meta: OperationMeta,
|
|
586
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
587
|
+
summary, description = meta.summary, meta.description
|
|
588
|
+
if not self.config.docstrings or (summary and description):
|
|
589
|
+
return summary, description
|
|
590
|
+
doc = inspect.getdoc(handler)
|
|
591
|
+
if not doc:
|
|
592
|
+
return summary, description
|
|
593
|
+
if summary is None:
|
|
594
|
+
first, _, rest = doc.partition('\n')
|
|
595
|
+
return first.strip(), description or (rest.strip() or None)
|
|
596
|
+
return summary, description or doc
|
|
597
|
+
|
|
598
|
+
def _parameters(
|
|
599
|
+
self,
|
|
600
|
+
meta: OperationMeta,
|
|
601
|
+
path: Optional[str],
|
|
602
|
+
defaults: tuple[Parameter, ...],
|
|
603
|
+
owner: str,
|
|
604
|
+
) -> Optional[list[JsonSchema]]:
|
|
605
|
+
explicit: dict[tuple[str, str], Parameter] = {}
|
|
606
|
+
for parameter in (*defaults, *meta.parameters):
|
|
607
|
+
explicit[_parameter_key(parameter.location, parameter.name)] = parameter
|
|
608
|
+
if path is not None:
|
|
609
|
+
for name in _PATH_PARAMETER.findall(path):
|
|
610
|
+
explicit.setdefault(('path', name), Parameter('path', name))
|
|
611
|
+
rendered = [self._parameter(parameter) for parameter in explicit.values()]
|
|
612
|
+
for model in meta.parameter_models:
|
|
613
|
+
self.expansions.append(
|
|
614
|
+
_ModelParameters(
|
|
615
|
+
rendered,
|
|
616
|
+
model.location,
|
|
617
|
+
{'schema': self.resolver.resolve(model.model, 'validation')},
|
|
618
|
+
set(explicit),
|
|
619
|
+
owner,
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
return rendered if rendered or meta.parameter_models else None
|
|
623
|
+
|
|
624
|
+
def _parameter(self, parameter: Parameter) -> JsonSchema:
|
|
625
|
+
rendered: JsonSchema = {'name': parameter.name, 'in': parameter.location}
|
|
626
|
+
if parameter.location == 'path' or parameter.required:
|
|
627
|
+
rendered['required'] = True
|
|
628
|
+
if parameter.description:
|
|
629
|
+
rendered['description'] = parameter.description
|
|
630
|
+
if parameter.deprecated:
|
|
631
|
+
rendered['deprecated'] = True
|
|
632
|
+
rendered['schema'] = self.resolver.resolve(parameter.schema, 'validation')
|
|
633
|
+
if parameter.examples:
|
|
634
|
+
rendered['examples'] = _render_examples(parameter.examples)
|
|
635
|
+
return rendered
|
|
636
|
+
|
|
637
|
+
def _expand(self, expansion: _ModelParameters) -> None:
|
|
638
|
+
schema = expansion.slot['schema']
|
|
639
|
+
ref = schema.get('$ref')
|
|
640
|
+
if isinstance(ref, str):
|
|
641
|
+
name = self.resolver.component_name(ref)
|
|
642
|
+
schema = (self.components.get(name) if name else None) or {}
|
|
643
|
+
properties = schema.get('properties')
|
|
644
|
+
if not isinstance(properties, Mapping):
|
|
645
|
+
raise BuildError(
|
|
646
|
+
f'{expansion.owner}: {expansion.location} model must describe an '
|
|
647
|
+
'object with properties',
|
|
648
|
+
)
|
|
649
|
+
required = set(cast('Iterable[str]', schema.get('required', ())))
|
|
650
|
+
for name, prop in cast('Mapping[str, JsonSchema]', properties).items():
|
|
651
|
+
if _parameter_key(expansion.location, name) in expansion.taken:
|
|
652
|
+
continue
|
|
653
|
+
prop_schema = copy.deepcopy(prop)
|
|
654
|
+
prop_schema.pop('title', None)
|
|
655
|
+
description = prop_schema.pop('description', None)
|
|
656
|
+
deprecated = prop_schema.pop('deprecated', False)
|
|
657
|
+
parameter: JsonSchema = {'name': name, 'in': expansion.location}
|
|
658
|
+
if expansion.location == 'path' or name in required:
|
|
659
|
+
parameter['required'] = True
|
|
660
|
+
if description:
|
|
661
|
+
parameter['description'] = description
|
|
662
|
+
if deprecated:
|
|
663
|
+
parameter['deprecated'] = True
|
|
664
|
+
parameter['schema'] = prop_schema
|
|
665
|
+
expansion.parameters.append(parameter)
|
|
666
|
+
|
|
667
|
+
def _responses(self, meta: OperationMeta, owner: str) -> JsonSchema:
|
|
668
|
+
drafts: dict[str, _ResponseDraft] = {}
|
|
669
|
+
for response in meta.responses:
|
|
670
|
+
draft = drafts.setdefault(_status_key(response.status), _ResponseDraft())
|
|
671
|
+
draft.description = response.description or draft.description
|
|
672
|
+
for content in response.content:
|
|
673
|
+
draft.content.setdefault(content.media_type, []).extend(
|
|
674
|
+
(schema, False) for schema in content.schemas
|
|
675
|
+
)
|
|
676
|
+
if content.examples:
|
|
677
|
+
draft.examples[content.media_type] = content.examples
|
|
678
|
+
for header in response.headers:
|
|
679
|
+
rendered: JsonSchema = {
|
|
680
|
+
'schema': self.resolver.resolve(header.schema, 'serialization'),
|
|
681
|
+
}
|
|
682
|
+
if header.description:
|
|
683
|
+
rendered['description'] = header.description
|
|
684
|
+
if header.required:
|
|
685
|
+
rendered['required'] = True
|
|
686
|
+
draft.headers[header.name] = rendered
|
|
687
|
+
for error in meta.errors:
|
|
688
|
+
described = self.errors.describe(error.error)
|
|
689
|
+
draft = drafts.setdefault(_status_key(described.status), _ResponseDraft())
|
|
690
|
+
draft.error_description = draft.error_description or described.description
|
|
691
|
+
draft.content.setdefault(error.media_type, []).append((error.error, True))
|
|
692
|
+
|
|
693
|
+
if not drafts:
|
|
694
|
+
if not self.config.default_response:
|
|
695
|
+
return {}
|
|
696
|
+
self.diagnostics.append(
|
|
697
|
+
Diagnostic(
|
|
698
|
+
'default-response',
|
|
699
|
+
'info',
|
|
700
|
+
'No responses described; added "200 OK"',
|
|
701
|
+
origins=(owner,),
|
|
702
|
+
),
|
|
703
|
+
)
|
|
704
|
+
return {'200': {'description': 'OK'}}
|
|
705
|
+
|
|
706
|
+
responses: JsonSchema = {}
|
|
707
|
+
for key in sorted(drafts, key=_status_order):
|
|
708
|
+
draft = drafts[key]
|
|
709
|
+
rendered = {
|
|
710
|
+
'description': draft.description
|
|
711
|
+
or draft.error_description
|
|
712
|
+
or _default_description(key),
|
|
713
|
+
}
|
|
714
|
+
if draft.headers:
|
|
715
|
+
rendered['headers'] = draft.headers
|
|
716
|
+
if draft.content:
|
|
717
|
+
content_map: JsonSchema = {}
|
|
718
|
+
for media_type, targets in draft.content.items():
|
|
719
|
+
media: JsonSchema = {}
|
|
720
|
+
if targets:
|
|
721
|
+
media['schema'] = self._content(targets, media_type)
|
|
722
|
+
if media_type in draft.examples:
|
|
723
|
+
media['examples'] = _render_examples(draft.examples[media_type])
|
|
724
|
+
content_map[media_type] = media
|
|
725
|
+
rendered['content'] = content_map
|
|
726
|
+
responses[key] = rendered
|
|
727
|
+
return responses
|
|
728
|
+
|
|
729
|
+
def _content(
|
|
730
|
+
self,
|
|
731
|
+
targets: Sequence[tuple[Any, bool]],
|
|
732
|
+
media_type: str,
|
|
733
|
+
) -> JsonSchema:
|
|
734
|
+
schemas: list[JsonSchema] = []
|
|
735
|
+
for target, is_error in targets:
|
|
736
|
+
if isinstance(target, (File, FileList)):
|
|
737
|
+
schemas.append(self.config.dialect.file_schema(target, media_type))
|
|
738
|
+
else:
|
|
739
|
+
provider = self.errors if is_error else None
|
|
740
|
+
schemas.append(self.resolver.resolve(target, 'serialization', provider))
|
|
741
|
+
unique: list[JsonSchema] = []
|
|
742
|
+
for schema in schemas:
|
|
743
|
+
if not any(schema is seen for seen in unique):
|
|
744
|
+
unique.append(schema)
|
|
745
|
+
return unique[0] if len(unique) == 1 else {'oneOf': unique}
|
|
746
|
+
|
|
747
|
+
def _security(self, meta: OperationMeta, owner: str) -> list[JsonSchema]:
|
|
748
|
+
requirements: list[JsonSchema] = []
|
|
749
|
+
for requirement in meta.security:
|
|
750
|
+
for name, _ in requirement.schemes:
|
|
751
|
+
if name not in self.config.security_schemes:
|
|
752
|
+
raise UnknownSecuritySchemeError(
|
|
753
|
+
f'{owner} requires security scheme {name!r}, which is not in '
|
|
754
|
+
'OpenAPI(security_schemes=...)',
|
|
755
|
+
)
|
|
756
|
+
requirements.append(
|
|
757
|
+
{name: list(scopes) for name, scopes in requirement.schemes},
|
|
758
|
+
)
|
|
759
|
+
return requirements
|
|
760
|
+
|
|
761
|
+
def _prune_components(self) -> None:
|
|
762
|
+
"""Drop schemas nobody references (e.g. models only expanded into parameters)."""
|
|
763
|
+
reachable: set[str] = set()
|
|
764
|
+
queue: list[Any] = [self.paths, self.webhooks]
|
|
765
|
+
while queue:
|
|
766
|
+
value = queue.pop()
|
|
767
|
+
if isinstance(value, dict):
|
|
768
|
+
mapping = cast('JsonSchema', value)
|
|
769
|
+
ref = mapping.get('$ref')
|
|
770
|
+
if isinstance(ref, str):
|
|
771
|
+
name = self.resolver.component_name(ref)
|
|
772
|
+
if name is not None and name not in reachable:
|
|
773
|
+
reachable.add(name)
|
|
774
|
+
queue.append(self.components.get(name))
|
|
775
|
+
queue.extend(mapping.values())
|
|
776
|
+
elif isinstance(value, list):
|
|
777
|
+
queue.extend(cast('list[object]', value))
|
|
778
|
+
self.components.keep(reachable)
|
|
779
|
+
|
|
780
|
+
# --- included documents ---------------------------------------------
|
|
781
|
+
|
|
782
|
+
def _merge_document(self, contribution: DocumentContribution) -> None:
|
|
783
|
+
for code, level, message in contribution.notes:
|
|
784
|
+
self.diagnostics.append(
|
|
785
|
+
Diagnostic(code, level, message, origins=(contribution.origin,)),
|
|
786
|
+
)
|
|
787
|
+
self._merge_items(self.paths, 'path', contribution.paths, contribution)
|
|
788
|
+
self._merge_items(self.webhooks, 'webhook', contribution.webhooks, contribution)
|
|
789
|
+
for section, entries in contribution.components.items():
|
|
790
|
+
for name, value in entries.items():
|
|
791
|
+
self._merge_component(section, name, value, contribution)
|
|
792
|
+
for tag in contribution.tags:
|
|
793
|
+
self._merge_named(
|
|
794
|
+
'tag',
|
|
795
|
+
str(tag.get('name')),
|
|
796
|
+
tag,
|
|
797
|
+
self._existing_tag,
|
|
798
|
+
self.raw_tags,
|
|
799
|
+
contribution,
|
|
800
|
+
)
|
|
801
|
+
for key, value in contribution.extensions.items():
|
|
802
|
+
self._merge_named(
|
|
803
|
+
'root',
|
|
804
|
+
key,
|
|
805
|
+
value,
|
|
806
|
+
self._existing_extension,
|
|
807
|
+
self.raw_extensions,
|
|
808
|
+
contribution,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
def _resolve_conflict(
|
|
812
|
+
self,
|
|
813
|
+
conflict: Conflict,
|
|
814
|
+
contribution: DocumentContribution,
|
|
815
|
+
error: type[BuildError],
|
|
816
|
+
) -> bool:
|
|
817
|
+
"""``True`` to take the incoming value."""
|
|
818
|
+
action = contribution.decide(conflict)
|
|
819
|
+
if action == 'error':
|
|
820
|
+
raise error(conflict.describe())
|
|
821
|
+
return action == 'replace'
|
|
822
|
+
|
|
823
|
+
def _merge_items(
|
|
824
|
+
self,
|
|
825
|
+
target: dict[str, dict[str, JsonSchema]],
|
|
826
|
+
kind: str,
|
|
827
|
+
items: Mapping[str, Any],
|
|
828
|
+
contribution: DocumentContribution,
|
|
829
|
+
) -> None:
|
|
830
|
+
for name, item in items.items():
|
|
831
|
+
slot = target.setdefault(name, {})
|
|
832
|
+
for key, value in cast('Mapping[str, Any]', item).items():
|
|
833
|
+
if key not in HTTP_METHODS:
|
|
834
|
+
if key in slot and slot[key] != value:
|
|
835
|
+
conflict = Conflict(
|
|
836
|
+
'path-item',
|
|
837
|
+
f'{name} {key}',
|
|
838
|
+
slot[key],
|
|
839
|
+
value,
|
|
840
|
+
'an earlier source',
|
|
841
|
+
contribution.origin,
|
|
842
|
+
)
|
|
843
|
+
if not self._resolve_conflict(
|
|
844
|
+
conflict,
|
|
845
|
+
contribution,
|
|
846
|
+
DocumentConflictError,
|
|
847
|
+
):
|
|
848
|
+
continue
|
|
849
|
+
slot[key] = value
|
|
850
|
+
continue
|
|
851
|
+
owner_key = (kind, name, key)
|
|
852
|
+
if owner_key in self.owners:
|
|
853
|
+
conflict = Conflict(
|
|
854
|
+
'operation' if kind == 'path' else 'webhook',
|
|
855
|
+
f'{key.upper()} {name}',
|
|
856
|
+
slot.get(key),
|
|
857
|
+
value,
|
|
858
|
+
self.owners[owner_key],
|
|
859
|
+
contribution.origin,
|
|
860
|
+
)
|
|
861
|
+
if not self._resolve_conflict(
|
|
862
|
+
conflict,
|
|
863
|
+
contribution,
|
|
864
|
+
OperationConflictError,
|
|
865
|
+
):
|
|
866
|
+
continue
|
|
867
|
+
previous_id = (slot.get(key) or {}).get('operationId')
|
|
868
|
+
self.operation_ids.pop(str(previous_id), None)
|
|
869
|
+
operation_id = cast('JsonSchema', value).get('operationId')
|
|
870
|
+
if isinstance(operation_id, str):
|
|
871
|
+
if operation_id in self.operation_ids:
|
|
872
|
+
raise DuplicateOperationIdError(
|
|
873
|
+
f'operationId {operation_id!r} is used by '
|
|
874
|
+
f'{self.operation_ids[operation_id]} and {contribution.origin}',
|
|
875
|
+
)
|
|
876
|
+
self.operation_ids[operation_id] = contribution.origin
|
|
877
|
+
self.owners[owner_key] = contribution.origin
|
|
878
|
+
slot[key] = value
|
|
879
|
+
|
|
880
|
+
def _merge_component(
|
|
881
|
+
self,
|
|
882
|
+
section: str,
|
|
883
|
+
name: str,
|
|
884
|
+
value: Any,
|
|
885
|
+
contribution: DocumentContribution,
|
|
886
|
+
) -> None:
|
|
887
|
+
raw = self.raw_components.setdefault(section, {})
|
|
888
|
+
existing: Any = None
|
|
889
|
+
existing_origin = 'the described operations'
|
|
890
|
+
if section == 'schemas':
|
|
891
|
+
existing = self.components.get(name)
|
|
892
|
+
elif section == 'securitySchemes':
|
|
893
|
+
existing = self.config.security_schemes.get(name)
|
|
894
|
+
existing_origin = 'OpenAPI(security_schemes=...)'
|
|
895
|
+
if name in raw:
|
|
896
|
+
existing, existing_origin = raw[name]
|
|
897
|
+
if existing is not None:
|
|
898
|
+
if existing == value:
|
|
899
|
+
return
|
|
900
|
+
conflict = Conflict(
|
|
901
|
+
'component',
|
|
902
|
+
f'components/{section}/{name}',
|
|
903
|
+
existing,
|
|
904
|
+
value,
|
|
905
|
+
existing_origin,
|
|
906
|
+
contribution.origin,
|
|
907
|
+
)
|
|
908
|
+
if not self._resolve_conflict(
|
|
909
|
+
conflict,
|
|
910
|
+
contribution,
|
|
911
|
+
ComponentConflictError,
|
|
912
|
+
):
|
|
913
|
+
return
|
|
914
|
+
raw[name] = (value, contribution.origin)
|
|
915
|
+
|
|
916
|
+
def _existing_tag(self, name: str) -> Optional[tuple[Any, str]]:
|
|
917
|
+
for tag in self.config.tags:
|
|
918
|
+
if tag.get('name') == name:
|
|
919
|
+
return tag, 'OpenAPI(tags=...)'
|
|
920
|
+
return None
|
|
921
|
+
|
|
922
|
+
def _existing_extension(self, key: str) -> Optional[tuple[Any, str]]:
|
|
923
|
+
if key in self.config.extensions:
|
|
924
|
+
return self.config.extensions[key], 'OpenAPI(extensions=...)'
|
|
925
|
+
return None
|
|
926
|
+
|
|
927
|
+
def _merge_named(
|
|
928
|
+
self,
|
|
929
|
+
kind: str,
|
|
930
|
+
key: str,
|
|
931
|
+
value: Any,
|
|
932
|
+
configured: Any,
|
|
933
|
+
raw: dict[str, tuple[Any, str]],
|
|
934
|
+
contribution: DocumentContribution,
|
|
935
|
+
) -> None:
|
|
936
|
+
existing = raw.get(key) or configured(key)
|
|
937
|
+
if existing is not None and existing[0] != value:
|
|
938
|
+
conflict = Conflict(
|
|
939
|
+
kind,
|
|
940
|
+
key,
|
|
941
|
+
existing[0],
|
|
942
|
+
value,
|
|
943
|
+
existing[1],
|
|
944
|
+
contribution.origin,
|
|
945
|
+
)
|
|
946
|
+
if not self._resolve_conflict(
|
|
947
|
+
conflict,
|
|
948
|
+
contribution,
|
|
949
|
+
DocumentConflictError,
|
|
950
|
+
):
|
|
951
|
+
return
|
|
952
|
+
raw[key] = (value, contribution.origin)
|
|
953
|
+
|
|
954
|
+
# --- document -------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
def _document(self) -> dict[str, Any]:
|
|
957
|
+
config = self.config
|
|
958
|
+
document: dict[str, Any] = {'info': copy.deepcopy(config.info)}
|
|
959
|
+
if config.servers:
|
|
960
|
+
document['servers'] = copy.deepcopy(config.servers)
|
|
961
|
+
document['paths'] = {
|
|
962
|
+
path: self._ordered_methods(self.paths[path]) for path in sorted(self.paths)
|
|
963
|
+
}
|
|
964
|
+
sections: dict[str, dict[str, Any]] = {}
|
|
965
|
+
if self.components.as_dict():
|
|
966
|
+
sections['schemas'] = self.components.as_dict()
|
|
967
|
+
if config.security_schemes:
|
|
968
|
+
sections['securitySchemes'] = copy.deepcopy(config.security_schemes)
|
|
969
|
+
for section, entries in self.raw_components.items():
|
|
970
|
+
target = sections.setdefault(section, {})
|
|
971
|
+
target.update({name: value for name, (value, _) in entries.items()})
|
|
972
|
+
components = {
|
|
973
|
+
section: {name: entries[name] for name in sorted(entries)}
|
|
974
|
+
for section, entries in sections.items()
|
|
975
|
+
if entries
|
|
976
|
+
}
|
|
977
|
+
if components:
|
|
978
|
+
document['components'] = components
|
|
979
|
+
tags = copy.deepcopy(config.tags)
|
|
980
|
+
tags = [self.raw_tags.pop(str(tag.get('name')), (tag, ''))[0] for tag in tags]
|
|
981
|
+
tags.extend(value for value, _ in self.raw_tags.values())
|
|
982
|
+
declared = {tag.get('name') for tag in tags}
|
|
983
|
+
tags.extend({'name': name} for name in self.used_tags if name not in declared)
|
|
984
|
+
if tags:
|
|
985
|
+
document['tags'] = tags
|
|
986
|
+
document.update(copy.deepcopy(config.extensions))
|
|
987
|
+
document.update({key: value for key, (value, _) in self.raw_extensions.items()})
|
|
988
|
+
return document
|
|
989
|
+
|
|
990
|
+
def _webhooks(self) -> dict[str, dict[str, JsonSchema]]:
|
|
991
|
+
return {name: self._ordered_methods(ops) for name, ops in self.webhooks.items()}
|
|
992
|
+
|
|
993
|
+
@staticmethod
|
|
994
|
+
def _ordered_methods(operations: Mapping[str, JsonSchema]) -> dict[str, JsonSchema]:
|
|
995
|
+
"""Path-item fields first (``parameters``, ``summary``...), then methods in order."""
|
|
996
|
+
ordered = {
|
|
997
|
+
key: value for key, value in operations.items() if key not in HTTP_METHODS
|
|
998
|
+
}
|
|
999
|
+
ordered.update(
|
|
1000
|
+
{
|
|
1001
|
+
method: operations[method]
|
|
1002
|
+
for method in HTTP_METHODS
|
|
1003
|
+
if method in operations
|
|
1004
|
+
},
|
|
1005
|
+
)
|
|
1006
|
+
return ordered
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def validate_document(document: Mapping[str, Any]) -> None:
|
|
1010
|
+
"""Check ``document`` with ``openapi-spec-validator``; 3.0 and 3.1 alike.
|
|
1011
|
+
|
|
1012
|
+
Raises :class:`InvalidDocumentError`; needs the ``validate`` extra.
|
|
1013
|
+
"""
|
|
1014
|
+
try:
|
|
1015
|
+
validator: Any = importlib.import_module('openapi_spec_validator')
|
|
1016
|
+
except ImportError as exc:
|
|
1017
|
+
raise ImportError(
|
|
1018
|
+
'validate=True needs openapi-spec-validator: '
|
|
1019
|
+
'install qstd-openapi[validate]',
|
|
1020
|
+
) from exc
|
|
1021
|
+
try:
|
|
1022
|
+
validator.validate(document)
|
|
1023
|
+
except Exception as exc:
|
|
1024
|
+
message = getattr(exc, 'message', None) or str(exc).split('\n', 1)[0]
|
|
1025
|
+
path = [str(part) for part in getattr(exc, 'path', ())]
|
|
1026
|
+
where = f' at {" > ".join(path)}' if path else ''
|
|
1027
|
+
raise InvalidDocumentError(
|
|
1028
|
+
f'The built document is not valid{where}: {message}',
|
|
1029
|
+
) from exc
|