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,47 @@
|
|
|
1
|
+
"""The protocol implemented by OpenAPI version dialects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, Optional, Protocol, Union
|
|
7
|
+
|
|
8
|
+
from qstd_openapi.markers import File, FileList
|
|
9
|
+
|
|
10
|
+
JsonSchema = dict[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OpenAPIDialect(Protocol):
|
|
14
|
+
"""Everything that depends on the OpenAPI version.
|
|
15
|
+
|
|
16
|
+
The rest of the library works with JSON Schema 2020-12 and
|
|
17
|
+
version-neutral markers; a dialect turns them into a concrete document.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def version(self) -> str:
|
|
22
|
+
"""Value of the root ``openapi`` field."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def file_schema(
|
|
26
|
+
self,
|
|
27
|
+
marker: Union[File, FileList],
|
|
28
|
+
media_type: Optional[str],
|
|
29
|
+
) -> JsonSchema:
|
|
30
|
+
"""Schema for binary content; ``media_type`` is set for whole bodies."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def check_document(self, document: Mapping[str, Any]) -> None:
|
|
34
|
+
"""Raise ``ValueError`` if an included document is not of this version."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def extract_webhooks(self, document: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
"""Remove webhooks from an included document and return them."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def finalize(
|
|
42
|
+
self,
|
|
43
|
+
document: Mapping[str, Any],
|
|
44
|
+
webhooks: Mapping[str, Mapping[str, Any]],
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Add version-specific root fields and return the final document."""
|
|
47
|
+
...
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""OpenAPI 3.0: converts JSON Schema 2020-12 into the 3.0 Schema Object subset.
|
|
2
|
+
|
|
3
|
+
What changes:
|
|
4
|
+
|
|
5
|
+
- ``type: [X, "null"]`` / ``anyOf: [..., {"type": "null"}]`` -> ``nullable: true``
|
|
6
|
+
(a nullable ``$ref`` becomes ``allOf: [$ref]`` + ``nullable``);
|
|
7
|
+
- ``const`` -> one-value ``enum``; ``examples`` -> ``example`` (the first one);
|
|
8
|
+
- numeric ``exclusiveMinimum``/``exclusiveMaximum`` -> ``minimum``/``maximum`` +
|
|
9
|
+
boolean flag;
|
|
10
|
+
- siblings of ``$ref`` (ignored by 3.0) -> ``allOf: [$ref]`` + siblings;
|
|
11
|
+
- ``prefixItems`` -> ``items: {anyOf: [...]}`` (the length limits stay);
|
|
12
|
+
- keywords 3.0 does not have (``if``/``then``/``else``, ``contentMediaType``,
|
|
13
|
+
``unevaluatedProperties``, ``patternProperties``...) are dropped;
|
|
14
|
+
- webhooks go to ``x-webhooks``; 3.1-only root/info fields are dropped;
|
|
15
|
+
- every operation has ``responses`` (3.0 requires it).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from typing import Any, Optional, Union, cast
|
|
22
|
+
|
|
23
|
+
from qstd_openapi.dialects.base import JsonSchema
|
|
24
|
+
from qstd_openapi.markers import File, FileList
|
|
25
|
+
|
|
26
|
+
_ROOT_ORDER = (
|
|
27
|
+
'openapi',
|
|
28
|
+
'info',
|
|
29
|
+
'servers',
|
|
30
|
+
'paths',
|
|
31
|
+
'components',
|
|
32
|
+
'security',
|
|
33
|
+
'tags',
|
|
34
|
+
'externalDocs',
|
|
35
|
+
)
|
|
36
|
+
_METHODS = ('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace')
|
|
37
|
+
_WEBHOOKS_31 = 'webhooks'
|
|
38
|
+
_WEBHOOKS_30 = 'x-webhooks'
|
|
39
|
+
_DROPPED = frozenset(
|
|
40
|
+
{
|
|
41
|
+
'$schema',
|
|
42
|
+
'$id',
|
|
43
|
+
'$anchor',
|
|
44
|
+
'$dynamicAnchor',
|
|
45
|
+
'$dynamicRef',
|
|
46
|
+
'$comment',
|
|
47
|
+
'$defs',
|
|
48
|
+
'contentMediaType',
|
|
49
|
+
'contentEncoding',
|
|
50
|
+
'contentSchema',
|
|
51
|
+
'if',
|
|
52
|
+
'then',
|
|
53
|
+
'else',
|
|
54
|
+
'dependentSchemas',
|
|
55
|
+
'dependentRequired',
|
|
56
|
+
'unevaluatedProperties',
|
|
57
|
+
'unevaluatedItems',
|
|
58
|
+
'patternProperties',
|
|
59
|
+
'propertyNames',
|
|
60
|
+
'contains',
|
|
61
|
+
'minContains',
|
|
62
|
+
'maxContains',
|
|
63
|
+
'const',
|
|
64
|
+
'examples',
|
|
65
|
+
'prefixItems',
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
_SUBSCHEMA_LISTS = ('allOf', 'anyOf', 'oneOf')
|
|
69
|
+
_SUBSCHEMA_SINGLE = ('items', 'not', 'additionalProperties')
|
|
70
|
+
_ANNOTATIONS = (
|
|
71
|
+
'title',
|
|
72
|
+
'description',
|
|
73
|
+
'default',
|
|
74
|
+
'example',
|
|
75
|
+
'deprecated',
|
|
76
|
+
'readOnly',
|
|
77
|
+
'writeOnly',
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_null(schema: Any) -> bool:
|
|
82
|
+
return (
|
|
83
|
+
isinstance(schema, dict)
|
|
84
|
+
and cast('JsonSchema', schema).get('type') == 'null'
|
|
85
|
+
and len(
|
|
86
|
+
cast('JsonSchema', schema),
|
|
87
|
+
)
|
|
88
|
+
== 1
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def convert_schema(schema: Any) -> Any:
|
|
93
|
+
"""Convert one JSON Schema 2020-12 schema (recursively) to OpenAPI 3.0."""
|
|
94
|
+
if not isinstance(schema, dict):
|
|
95
|
+
return schema
|
|
96
|
+
source = cast('JsonSchema', schema)
|
|
97
|
+
result: JsonSchema = {}
|
|
98
|
+
nullable = False
|
|
99
|
+
|
|
100
|
+
for key, value in source.items():
|
|
101
|
+
if key == 'properties' and isinstance(value, dict):
|
|
102
|
+
result[key] = {
|
|
103
|
+
name: convert_schema(item)
|
|
104
|
+
for name, item in cast('JsonSchema', value).items()
|
|
105
|
+
}
|
|
106
|
+
elif key in _SUBSCHEMA_LISTS and isinstance(value, list):
|
|
107
|
+
items = [item for item in cast('list[object]', value) if not _is_null(item)]
|
|
108
|
+
if len(items) != len(cast('list[object]', value)):
|
|
109
|
+
nullable = True
|
|
110
|
+
if items:
|
|
111
|
+
result[key] = [convert_schema(item) for item in items]
|
|
112
|
+
elif key in _SUBSCHEMA_SINGLE and isinstance(value, dict):
|
|
113
|
+
result[key] = convert_schema(value)
|
|
114
|
+
elif key == 'type' and isinstance(value, list):
|
|
115
|
+
types = [t for t in cast('list[str]', value) if t != 'null']
|
|
116
|
+
nullable = nullable or len(types) != len(cast('list[str]', value))
|
|
117
|
+
if len(types) == 1:
|
|
118
|
+
result['type'] = types[0]
|
|
119
|
+
elif types:
|
|
120
|
+
result['anyOf'] = [{'type': t} for t in types]
|
|
121
|
+
elif key == 'type' and value == 'null':
|
|
122
|
+
nullable = True
|
|
123
|
+
elif key == 'enum' and isinstance(value, list):
|
|
124
|
+
values = cast('list[object]', value)
|
|
125
|
+
if None in values:
|
|
126
|
+
nullable = True
|
|
127
|
+
result['enum'] = [item for item in values if item is not None]
|
|
128
|
+
elif key in ('exclusiveMinimum', 'exclusiveMaximum') and _is_number(value):
|
|
129
|
+
bound = 'minimum' if key == 'exclusiveMinimum' else 'maximum'
|
|
130
|
+
result[bound] = value
|
|
131
|
+
result[key] = True
|
|
132
|
+
elif key not in _DROPPED:
|
|
133
|
+
result[key] = value
|
|
134
|
+
|
|
135
|
+
if 'const' in source:
|
|
136
|
+
if source['const'] is None:
|
|
137
|
+
nullable = True
|
|
138
|
+
else:
|
|
139
|
+
result['enum'] = [source['const']]
|
|
140
|
+
examples = source.get('examples')
|
|
141
|
+
if isinstance(examples, list) and examples and 'example' not in result:
|
|
142
|
+
result['example'] = cast('list[object]', examples)[0]
|
|
143
|
+
prefix = source.get('prefixItems')
|
|
144
|
+
if isinstance(prefix, list) and 'items' not in result:
|
|
145
|
+
result['items'] = {
|
|
146
|
+
'anyOf': [convert_schema(item) for item in cast('list[object]', prefix)],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
result = _collapse_single_any_of(result)
|
|
150
|
+
if nullable:
|
|
151
|
+
result = _make_nullable(result)
|
|
152
|
+
if 'allOf' in result and 'default' in result and result['default'] is None:
|
|
153
|
+
# 3.0 validators check ``default: null`` against the referenced
|
|
154
|
+
# (non-nullable) schema; for a nullable reference it is only an annotation.
|
|
155
|
+
del result['default']
|
|
156
|
+
if '$ref' in result and len(result) > 1:
|
|
157
|
+
siblings = {k: v for k, v in result.items() if k != '$ref'}
|
|
158
|
+
result = {'allOf': [{'$ref': result['$ref']}], **siblings}
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_number(value: object) -> bool:
|
|
163
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _collapse_single_any_of(schema: JsonSchema) -> JsonSchema:
|
|
167
|
+
"""``{anyOf: [S], title: ...}`` (left after removing ``null``) -> S + annotations."""
|
|
168
|
+
any_of = schema.get('anyOf')
|
|
169
|
+
if not (isinstance(any_of, list) and len(cast('list[object]', any_of)) == 1):
|
|
170
|
+
return schema
|
|
171
|
+
only = cast('JsonSchema', cast('list[object]', any_of)[0])
|
|
172
|
+
rest = {k: v for k, v in schema.items() if k != 'anyOf'}
|
|
173
|
+
if '$ref' in only:
|
|
174
|
+
return {'allOf': [only], **rest}
|
|
175
|
+
if any(key in only for key in rest):
|
|
176
|
+
return schema
|
|
177
|
+
return {**only, **rest}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _make_nullable(schema: JsonSchema) -> JsonSchema:
|
|
181
|
+
if '$ref' in schema:
|
|
182
|
+
siblings = {k: v for k, v in schema.items() if k != '$ref'}
|
|
183
|
+
return {'allOf': [{'$ref': schema['$ref']}], **siblings, 'nullable': True}
|
|
184
|
+
return {**schema, 'nullable': True}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class OpenAPI30:
|
|
188
|
+
"""OpenAPI 3.0.x."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, version: str = '3.0.3') -> None:
|
|
191
|
+
if not version.startswith('3.0.'):
|
|
192
|
+
raise ValueError(f'OpenAPI30 cannot produce version {version!r}')
|
|
193
|
+
self._version = version
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def version(self) -> str:
|
|
197
|
+
return self._version
|
|
198
|
+
|
|
199
|
+
def file_schema(
|
|
200
|
+
self,
|
|
201
|
+
marker: Union[File, FileList],
|
|
202
|
+
media_type: Optional[str], # noqa: ARG002 - 3.0 has no contentMediaType
|
|
203
|
+
) -> JsonSchema:
|
|
204
|
+
if isinstance(marker, FileList):
|
|
205
|
+
schema: JsonSchema = {
|
|
206
|
+
'type': 'array',
|
|
207
|
+
'items': self.file_schema(File(marker.description), None),
|
|
208
|
+
}
|
|
209
|
+
if marker.max_items is not None:
|
|
210
|
+
schema['maxItems'] = marker.max_items
|
|
211
|
+
return schema
|
|
212
|
+
schema = {'type': 'string', 'format': 'binary'}
|
|
213
|
+
if marker.description:
|
|
214
|
+
schema['description'] = marker.description
|
|
215
|
+
return schema
|
|
216
|
+
|
|
217
|
+
def check_document(self, document: Mapping[str, Any]) -> None:
|
|
218
|
+
"""3.0 documents are taken as they are; 3.1 ones are converted on output."""
|
|
219
|
+
version = document.get('openapi')
|
|
220
|
+
if not isinstance(version, str) or not version.startswith(('3.0.', '3.1.')):
|
|
221
|
+
raise ValueError(f'document version {version!r} is neither 3.0.x nor 3.1.x')
|
|
222
|
+
|
|
223
|
+
def extract_webhooks(self, document: dict[str, Any]) -> dict[str, Any]:
|
|
224
|
+
hooks = cast('dict[str, Any]', document.pop(_WEBHOOKS_30, None) or {})
|
|
225
|
+
hooks.update(cast('dict[str, Any]', document.pop(_WEBHOOKS_31, None) or {}))
|
|
226
|
+
return hooks
|
|
227
|
+
|
|
228
|
+
def finalize(
|
|
229
|
+
self,
|
|
230
|
+
document: Mapping[str, Any],
|
|
231
|
+
webhooks: Mapping[str, Mapping[str, Any]],
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
fields: dict[str, Any] = {
|
|
234
|
+
key: value
|
|
235
|
+
for key, value in document.items()
|
|
236
|
+
if key not in ('jsonSchemaDialect', _WEBHOOKS_31)
|
|
237
|
+
}
|
|
238
|
+
fields['openapi'] = self._version
|
|
239
|
+
fields['info'] = _info(cast('Mapping[str, Any]', fields.get('info', {})))
|
|
240
|
+
fields['paths'] = {
|
|
241
|
+
path: _path_item(cast('Mapping[str, Any]', item))
|
|
242
|
+
for path, item in cast('Mapping[str, Any]', fields.get('paths', {})).items()
|
|
243
|
+
}
|
|
244
|
+
hooks = {
|
|
245
|
+
**cast('Mapping[str, Any]', document.get(_WEBHOOKS_31, {})),
|
|
246
|
+
**webhooks,
|
|
247
|
+
}
|
|
248
|
+
if hooks:
|
|
249
|
+
fields[_WEBHOOKS_30] = {
|
|
250
|
+
name: _path_item(cast('Mapping[str, Any]', hooks[name]))
|
|
251
|
+
for name in sorted(hooks)
|
|
252
|
+
}
|
|
253
|
+
components = fields.get('components')
|
|
254
|
+
if isinstance(components, dict):
|
|
255
|
+
fields['components'] = _components(cast('Mapping[str, Any]', components))
|
|
256
|
+
ordered = {key: fields[key] for key in _ROOT_ORDER if key in fields}
|
|
257
|
+
ordered.update(
|
|
258
|
+
{key: value for key, value in fields.items() if key not in ordered},
|
|
259
|
+
)
|
|
260
|
+
return ordered
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _info(info: Mapping[str, Any]) -> dict[str, Any]:
|
|
264
|
+
result = {key: value for key, value in info.items() if key != 'summary'}
|
|
265
|
+
license_info = result.get('license')
|
|
266
|
+
if isinstance(license_info, dict):
|
|
267
|
+
result['license'] = {
|
|
268
|
+
k: v
|
|
269
|
+
for k, v in cast('Mapping[str, Any]', license_info).items()
|
|
270
|
+
if k != 'identifier'
|
|
271
|
+
}
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _convert_schemas_in(value: Any) -> Any:
|
|
276
|
+
"""Convert every ``schema`` found under parameters, headers and media types."""
|
|
277
|
+
if isinstance(value, list):
|
|
278
|
+
return [_convert_schemas_in(item) for item in cast('list[object]', value)]
|
|
279
|
+
if not isinstance(value, dict):
|
|
280
|
+
return value
|
|
281
|
+
return {
|
|
282
|
+
key: convert_schema(item) if key == 'schema' else _convert_schemas_in(item)
|
|
283
|
+
for key, item in cast('Mapping[str, Any]', value).items()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _path_item(item: Mapping[str, Any]) -> dict[str, Any]:
|
|
288
|
+
result = cast('dict[str, Any]', _convert_schemas_in(item))
|
|
289
|
+
for method in _METHODS:
|
|
290
|
+
operation = result.get(method)
|
|
291
|
+
if isinstance(operation, dict) and not cast('Mapping[str, Any]', operation).get(
|
|
292
|
+
'responses',
|
|
293
|
+
):
|
|
294
|
+
cast('dict[str, Any]', operation)['responses'] = {
|
|
295
|
+
'default': {'description': 'Default response'},
|
|
296
|
+
}
|
|
297
|
+
return result
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _components(components: Mapping[str, Any]) -> dict[str, Any]:
|
|
301
|
+
result: dict[str, Any] = {}
|
|
302
|
+
for section, entries in components.items():
|
|
303
|
+
if section == 'pathItems': # 3.1 only
|
|
304
|
+
continue
|
|
305
|
+
if section == 'schemas':
|
|
306
|
+
result[section] = {
|
|
307
|
+
name: convert_schema(schema)
|
|
308
|
+
for name, schema in cast('Mapping[str, Any]', entries).items()
|
|
309
|
+
}
|
|
310
|
+
else:
|
|
311
|
+
result[section] = _convert_schemas_in(entries)
|
|
312
|
+
return result
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The default OpenAPI 3.1 dialect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, Optional, Union, cast
|
|
7
|
+
|
|
8
|
+
from qstd_openapi.dialects.base import JsonSchema
|
|
9
|
+
from qstd_openapi.markers import File, FileList
|
|
10
|
+
|
|
11
|
+
_ROOT_ORDER = (
|
|
12
|
+
'openapi',
|
|
13
|
+
'info',
|
|
14
|
+
'jsonSchemaDialect',
|
|
15
|
+
'servers',
|
|
16
|
+
'paths',
|
|
17
|
+
'webhooks',
|
|
18
|
+
'components',
|
|
19
|
+
'security',
|
|
20
|
+
'tags',
|
|
21
|
+
'externalDocs',
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpenAPI31:
|
|
26
|
+
"""OpenAPI 3.1: schemas are JSON Schema 2020-12 as they are."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, version: str = '3.1.0') -> None:
|
|
29
|
+
if not version.startswith('3.1.'):
|
|
30
|
+
raise ValueError(f'OpenAPI31 cannot produce version {version!r}')
|
|
31
|
+
self._version = version
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def version(self) -> str:
|
|
35
|
+
return self._version
|
|
36
|
+
|
|
37
|
+
def file_schema(
|
|
38
|
+
self,
|
|
39
|
+
marker: Union[File, FileList],
|
|
40
|
+
media_type: Optional[str],
|
|
41
|
+
) -> JsonSchema:
|
|
42
|
+
if isinstance(marker, FileList):
|
|
43
|
+
schema: JsonSchema = {
|
|
44
|
+
'type': 'array',
|
|
45
|
+
'items': self.file_schema(File(marker.description), None),
|
|
46
|
+
}
|
|
47
|
+
if marker.max_items is not None:
|
|
48
|
+
schema['maxItems'] = marker.max_items
|
|
49
|
+
return schema
|
|
50
|
+
# ``format: binary`` is an annotation in 3.1 but still what UIs look for.
|
|
51
|
+
schema = {'type': 'string', 'format': 'binary'}
|
|
52
|
+
if media_type and '*' not in media_type:
|
|
53
|
+
schema['contentMediaType'] = media_type
|
|
54
|
+
if marker.description:
|
|
55
|
+
schema['description'] = marker.description
|
|
56
|
+
return schema
|
|
57
|
+
|
|
58
|
+
def check_document(self, document: Mapping[str, Any]) -> None:
|
|
59
|
+
version = document.get('openapi')
|
|
60
|
+
if not isinstance(version, str) or not version.startswith('3.1.'):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f'document version {version!r} is not 3.1.x; convert it first',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def extract_webhooks(self, document: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
+
return cast('dict[str, Any]', document.pop('webhooks', None) or {})
|
|
67
|
+
|
|
68
|
+
def finalize(
|
|
69
|
+
self,
|
|
70
|
+
document: Mapping[str, Any],
|
|
71
|
+
webhooks: Mapping[str, Mapping[str, Any]],
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
fields: dict[str, Any] = {**document, 'openapi': self._version}
|
|
74
|
+
if webhooks:
|
|
75
|
+
fields['webhooks'] = {
|
|
76
|
+
name: dict(webhooks[name]) for name in sorted(webhooks)
|
|
77
|
+
}
|
|
78
|
+
ordered = {key: fields[key] for key in _ROOT_ORDER if key in fields}
|
|
79
|
+
ordered.update(
|
|
80
|
+
{key: value for key, value in fields.items() if key not in ordered},
|
|
81
|
+
)
|
|
82
|
+
return ordered
|
qstd_openapi/errors.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Exceptions raised while attaching metadata and building documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from qstd_openapi.meta.model import Origin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QstdOpenAPIError(Exception):
|
|
13
|
+
"""Base class for all errors raised by the library."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AttachError(QstdOpenAPIError, TypeError):
|
|
17
|
+
"""OpenAPI metadata cannot be attached to the given object."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BuildError(QstdOpenAPIError):
|
|
21
|
+
"""A document cannot be built; ``code`` is stable and machine-readable."""
|
|
22
|
+
|
|
23
|
+
code: str = 'build-error'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ScalarConflictError(BuildError, ValueError):
|
|
27
|
+
"""A single-valued field received different values from several places."""
|
|
28
|
+
|
|
29
|
+
code = 'scalar-conflict'
|
|
30
|
+
field: str
|
|
31
|
+
values: tuple[tuple[Any, Origin], ...]
|
|
32
|
+
|
|
33
|
+
def __init__(self, field: str, values: Sequence[tuple[Any, Origin]]) -> None:
|
|
34
|
+
self.field = field
|
|
35
|
+
self.values = tuple(values)
|
|
36
|
+
listed = '; '.join(f'{value!r} from {origin}' for value, origin in self.values)
|
|
37
|
+
super().__init__(
|
|
38
|
+
f'Conflicting values for {field!r}: {listed}. '
|
|
39
|
+
"Set it in one place or build with scalar_conflicts='last_wins'.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OperationConflictError(BuildError):
|
|
44
|
+
"""Two handlers are registered for the same path and method (or webhook)."""
|
|
45
|
+
|
|
46
|
+
code = 'operation-conflict'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DuplicateOperationIdError(BuildError):
|
|
50
|
+
"""Two operations resolved to the same ``operationId``."""
|
|
51
|
+
|
|
52
|
+
code = 'duplicate-operation-id'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ComponentConflictError(BuildError):
|
|
56
|
+
"""Two different schemas want the same component name."""
|
|
57
|
+
|
|
58
|
+
code = 'component-conflict'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UnsupportedSchemaError(BuildError):
|
|
62
|
+
"""No schema provider can turn the object into a schema.
|
|
63
|
+
|
|
64
|
+
``target`` is the object; ``location`` is the path to it inside a raw
|
|
65
|
+
schema (``('properties', 'origin')``), empty when it was passed directly.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
code = 'unsupported-schema'
|
|
69
|
+
target: Any
|
|
70
|
+
location: tuple[str, ...]
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
message: str,
|
|
75
|
+
*,
|
|
76
|
+
target: Any = None,
|
|
77
|
+
location: Sequence[str] = (),
|
|
78
|
+
) -> None:
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
self.target = target
|
|
81
|
+
self.location = tuple(location)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UnsupportedErrorObjectError(BuildError):
|
|
85
|
+
"""No error provider can describe the object passed to ``errors``."""
|
|
86
|
+
|
|
87
|
+
code = 'unsupported-error'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class UnknownSecuritySchemeError(BuildError):
|
|
91
|
+
"""An operation refers to a security scheme the document does not define."""
|
|
92
|
+
|
|
93
|
+
code = 'unknown-security-scheme'
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ErrorAnnotationError(BuildError):
|
|
97
|
+
"""An error class annotation cannot be evaluated."""
|
|
98
|
+
|
|
99
|
+
code = 'error-annotation'
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class InvalidDocumentError(BuildError):
|
|
103
|
+
"""The built document does not pass ``openapi-spec-validator`` (``validate=True``)."""
|
|
104
|
+
|
|
105
|
+
code = 'invalid-document'
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class UnsupportedDocumentError(BuildError):
|
|
109
|
+
"""An included document has an unsupported version or structure."""
|
|
110
|
+
|
|
111
|
+
code = 'unsupported-document'
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DocumentConflictError(BuildError):
|
|
115
|
+
"""An included document collides with another source (tags, root fields, path items)."""
|
|
116
|
+
|
|
117
|
+
code = 'document-conflict'
|