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,554 @@
|
|
|
1
|
+
"""Turning schema references into JSON Schema 2020-12.
|
|
2
|
+
|
|
3
|
+
Providers are asked in batches: while operations are rendered every schema
|
|
4
|
+
reference becomes a placeholder, and only when everything has been seen are
|
|
5
|
+
the providers run. That lets a provider such as Pydantic see all models at
|
|
6
|
+
once and name input/output variants consistently. Placeholders are then
|
|
7
|
+
replaced in the whole document.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import dataclasses
|
|
14
|
+
import datetime
|
|
15
|
+
import decimal
|
|
16
|
+
import enum
|
|
17
|
+
import sys
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
from collections.abc import Mapping, Sequence
|
|
21
|
+
from types import MappingProxyType
|
|
22
|
+
from typing import (
|
|
23
|
+
TYPE_CHECKING,
|
|
24
|
+
Annotated,
|
|
25
|
+
Any,
|
|
26
|
+
Literal,
|
|
27
|
+
Optional,
|
|
28
|
+
Protocol,
|
|
29
|
+
Union,
|
|
30
|
+
cast,
|
|
31
|
+
get_args,
|
|
32
|
+
get_origin,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from qstd_openapi._compat import is_class
|
|
36
|
+
from qstd_openapi.errors import ComponentConflictError, UnsupportedSchemaError
|
|
37
|
+
from qstd_openapi.markers import File, FileList, FormFields, Ref
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from qstd_openapi.dialects.base import OpenAPIDialect
|
|
41
|
+
|
|
42
|
+
JsonSchema = dict[str, Any]
|
|
43
|
+
SchemaMode = Literal['validation', 'serialization']
|
|
44
|
+
REF_TEMPLATE = '#/components/schemas/{model}'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclasses.dataclass(frozen=True)
|
|
48
|
+
class SchemaRequest:
|
|
49
|
+
target: Any
|
|
50
|
+
mode: SchemaMode
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
OverrideWithin = Literal['models', 'dataclasses']
|
|
54
|
+
"""Whose fields an override is limited to: Pydantic models or dataclasses."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclasses.dataclass(frozen=True)
|
|
58
|
+
class TypeOverride:
|
|
59
|
+
"""A schema used for a Python type instead of the one a provider would give.
|
|
60
|
+
|
|
61
|
+
Use it when the application serializes a type differently from what the
|
|
62
|
+
provider assumes (a custom JSON encoder turning ``Decimal`` into a number).
|
|
63
|
+
|
|
64
|
+
``mode`` limits the override to request data (``'validation'``) or to
|
|
65
|
+
responses (``'serialization'``); ``within`` limits it to fields declared in
|
|
66
|
+
Pydantic models (``'models'``) or in dataclasses (``'dataclasses'``).
|
|
67
|
+
``None`` means everywhere. The schema is plain JSON Schema (no markers).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
schema: Mapping[str, Any]
|
|
71
|
+
mode: Optional[SchemaMode] = None
|
|
72
|
+
within: Optional[OverrideWithin] = None
|
|
73
|
+
|
|
74
|
+
def applies(self, mode: SchemaMode, within: Optional[OverrideWithin]) -> bool:
|
|
75
|
+
return (self.mode is None or self.mode == mode) and (
|
|
76
|
+
self.within is None or self.within == within
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
TypeOverrideValue = Union[Mapping[str, Any], TypeOverride, Sequence[TypeOverride]]
|
|
81
|
+
"""A plain schema (everywhere), one override or several (the first that applies)."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TypeOverrides:
|
|
85
|
+
"""``type_overrides`` of a document, normalized; keys are classes."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
overrides: Optional[Mapping[Any, TypeOverrideValue]] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._overrides: dict[Any, tuple[TypeOverride, ...]] = {}
|
|
92
|
+
for target, value in (overrides or {}).items():
|
|
93
|
+
if not is_class(target):
|
|
94
|
+
raise TypeError(
|
|
95
|
+
f'type_overrides keys must be classes, got {target!r}',
|
|
96
|
+
)
|
|
97
|
+
if isinstance(value, TypeOverride):
|
|
98
|
+
items: tuple[TypeOverride, ...] = (value,)
|
|
99
|
+
elif isinstance(value, Mapping):
|
|
100
|
+
items = (TypeOverride(value),)
|
|
101
|
+
else:
|
|
102
|
+
items = tuple(value)
|
|
103
|
+
for item in items:
|
|
104
|
+
if not isinstance(
|
|
105
|
+
item,
|
|
106
|
+
TypeOverride,
|
|
107
|
+
): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
108
|
+
raise TypeError(
|
|
109
|
+
f'type_overrides[{target!r}] must be a schema dict, '
|
|
110
|
+
f'a TypeOverride or a list of them, got {item!r}',
|
|
111
|
+
)
|
|
112
|
+
self._overrides[target] = items
|
|
113
|
+
|
|
114
|
+
def __bool__(self) -> bool:
|
|
115
|
+
return bool(self._overrides)
|
|
116
|
+
|
|
117
|
+
def lookup(
|
|
118
|
+
self,
|
|
119
|
+
target: Any,
|
|
120
|
+
mode: SchemaMode,
|
|
121
|
+
within: Optional[OverrideWithin] = None,
|
|
122
|
+
) -> Optional[JsonSchema]:
|
|
123
|
+
"""A private copy of the schema overriding ``target``, if any applies."""
|
|
124
|
+
try:
|
|
125
|
+
items = self._overrides.get(target, ())
|
|
126
|
+
except TypeError: # unhashable target
|
|
127
|
+
return None
|
|
128
|
+
for item in items:
|
|
129
|
+
if item.applies(mode, within):
|
|
130
|
+
return copy.deepcopy(dict(item.schema))
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class SchemaContext(Protocol):
|
|
135
|
+
"""What a provider may use while generating schemas."""
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def ref_template(self) -> str: ...
|
|
139
|
+
|
|
140
|
+
def type_override(
|
|
141
|
+
self,
|
|
142
|
+
target: Any,
|
|
143
|
+
mode: SchemaMode,
|
|
144
|
+
within: Optional[OverrideWithin] = None,
|
|
145
|
+
) -> Optional[JsonSchema]:
|
|
146
|
+
"""The document's schema for ``target`` when it overrides the provider's.
|
|
147
|
+
|
|
148
|
+
``within`` tells whose field is being described (a model's or a
|
|
149
|
+
dataclass's); ``None`` for a type that is not a field.
|
|
150
|
+
"""
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
def resolve(self, target: Any, mode: SchemaMode) -> JsonSchema:
|
|
154
|
+
"""Schema (possibly a placeholder) for a nested reference."""
|
|
155
|
+
...
|
|
156
|
+
|
|
157
|
+
def add_component(self, name: str, schema: JsonSchema, origin: str) -> JsonSchema:
|
|
158
|
+
"""Register ``components/schemas/<name>`` and return a ``$ref`` to it."""
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SchemaProvider(Protocol):
|
|
163
|
+
def supports(self, target: object) -> bool: ...
|
|
164
|
+
|
|
165
|
+
def generate(
|
|
166
|
+
self,
|
|
167
|
+
requests: Sequence[SchemaRequest],
|
|
168
|
+
context: SchemaContext,
|
|
169
|
+
) -> Sequence[JsonSchema]:
|
|
170
|
+
"""One schema per request, in the same order."""
|
|
171
|
+
...
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Components:
|
|
175
|
+
"""``components/schemas`` with strict name-conflict detection."""
|
|
176
|
+
|
|
177
|
+
def __init__(self) -> None:
|
|
178
|
+
self._schemas: dict[str, tuple[JsonSchema, str]] = {}
|
|
179
|
+
|
|
180
|
+
def add(self, name: str, schema: JsonSchema, origin: str) -> None:
|
|
181
|
+
existing = self._schemas.get(name)
|
|
182
|
+
if existing is None:
|
|
183
|
+
self._schemas[name] = (schema, origin)
|
|
184
|
+
return
|
|
185
|
+
if existing[0] != schema:
|
|
186
|
+
raise ComponentConflictError(
|
|
187
|
+
f'Component schema {name!r} is defined differently by {existing[1]} '
|
|
188
|
+
f'and {origin}. Rename one of the models.',
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def get(self, name: str) -> Optional[JsonSchema]:
|
|
192
|
+
item = self._schemas.get(name)
|
|
193
|
+
return item[0] if item else None
|
|
194
|
+
|
|
195
|
+
def replace_all(self, schemas: Mapping[str, JsonSchema]) -> None:
|
|
196
|
+
for name, schema in schemas.items():
|
|
197
|
+
self._schemas[name] = (schema, self._schemas[name][1])
|
|
198
|
+
|
|
199
|
+
def keep(self, names: set[str]) -> None:
|
|
200
|
+
self._schemas = {k: v for k, v in self._schemas.items() if k in names}
|
|
201
|
+
|
|
202
|
+
def as_dict(self) -> dict[str, JsonSchema]:
|
|
203
|
+
return {name: self._schemas[name][0] for name in sorted(self._schemas)}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _Pending:
|
|
207
|
+
"""Placeholder inserted into the document until the provider has run.
|
|
208
|
+
|
|
209
|
+
Compared by identity, so schemas holding different placeholders are
|
|
210
|
+
never mistaken for equal ones.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
__slots__ = ('provider', 'request', 'result')
|
|
214
|
+
|
|
215
|
+
def __init__(self, provider: SchemaProvider, request: SchemaRequest) -> None:
|
|
216
|
+
self.provider = provider
|
|
217
|
+
self.request = request
|
|
218
|
+
self.result: Optional[JsonSchema] = None
|
|
219
|
+
|
|
220
|
+
def __copy__(self) -> _Pending:
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> _Pending:
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _key(target: Any) -> Any:
|
|
228
|
+
try:
|
|
229
|
+
hash(target)
|
|
230
|
+
except TypeError:
|
|
231
|
+
return ('id', id(target))
|
|
232
|
+
return target
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class SchemaResolver:
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
providers: Sequence[SchemaProvider],
|
|
239
|
+
dialect: OpenAPIDialect,
|
|
240
|
+
components: Components,
|
|
241
|
+
ref_template: str = REF_TEMPLATE,
|
|
242
|
+
type_overrides: Optional[TypeOverrides] = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
self._providers = list(providers)
|
|
245
|
+
self._dialect = dialect
|
|
246
|
+
self.components = components
|
|
247
|
+
self._ref_template = ref_template
|
|
248
|
+
self._overrides = type_overrides or TypeOverrides()
|
|
249
|
+
self._pending: dict[tuple[int, Any, str], _Pending] = {}
|
|
250
|
+
self._queue: list[_Pending] = []
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def ref_template(self) -> str:
|
|
254
|
+
return self._ref_template
|
|
255
|
+
|
|
256
|
+
def type_override(
|
|
257
|
+
self,
|
|
258
|
+
target: Any,
|
|
259
|
+
mode: SchemaMode,
|
|
260
|
+
within: Optional[OverrideWithin] = None,
|
|
261
|
+
) -> Optional[JsonSchema]:
|
|
262
|
+
return self._overrides.lookup(target, mode, within)
|
|
263
|
+
|
|
264
|
+
def ref(self, name: str) -> JsonSchema:
|
|
265
|
+
return {'$ref': self._ref_template.format(model=name)}
|
|
266
|
+
|
|
267
|
+
def component_name(self, ref: str) -> Optional[str]:
|
|
268
|
+
prefix, _, suffix = self._ref_template.partition('{model}')
|
|
269
|
+
if ref.startswith(prefix) and ref.endswith(suffix):
|
|
270
|
+
return ref[len(prefix) : len(ref) - len(suffix)]
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
def add_component(self, name: str, schema: JsonSchema, origin: str) -> JsonSchema:
|
|
274
|
+
self.components.add(name, schema, origin)
|
|
275
|
+
return self.ref(name)
|
|
276
|
+
|
|
277
|
+
def resolve(
|
|
278
|
+
self,
|
|
279
|
+
target: Any,
|
|
280
|
+
mode: SchemaMode,
|
|
281
|
+
provider: Optional[SchemaProvider] = None,
|
|
282
|
+
) -> JsonSchema:
|
|
283
|
+
if provider is None:
|
|
284
|
+
if isinstance(target, Mapping):
|
|
285
|
+
return self._resolve_raw(cast('Mapping[str, Any]', target), mode)
|
|
286
|
+
if isinstance(target, Ref):
|
|
287
|
+
return self.resolve(
|
|
288
|
+
target.target,
|
|
289
|
+
cast('SchemaMode', target.mode or mode),
|
|
290
|
+
)
|
|
291
|
+
if isinstance(target, (File, FileList)):
|
|
292
|
+
return self._dialect.file_schema(target, None)
|
|
293
|
+
if isinstance(target, FormFields):
|
|
294
|
+
return self._form(target, mode)
|
|
295
|
+
override = self._overrides.lookup(target, mode)
|
|
296
|
+
if override is not None:
|
|
297
|
+
return override
|
|
298
|
+
provider = self._provider_for(target)
|
|
299
|
+
key = (id(provider), _key(target), mode)
|
|
300
|
+
pending = self._pending.get(key)
|
|
301
|
+
if pending is None:
|
|
302
|
+
pending = _Pending(provider, SchemaRequest(target, mode))
|
|
303
|
+
if isinstance(provider, BuiltinSchemas):
|
|
304
|
+
# Built-in schemas need no batching. Resolve them immediately so
|
|
305
|
+
# an unsupported nested type fails while its context is still known.
|
|
306
|
+
pending.result = provider.generate([pending.request], self)[0]
|
|
307
|
+
else:
|
|
308
|
+
self._queue.append(pending)
|
|
309
|
+
self._pending[key] = pending
|
|
310
|
+
return cast('JsonSchema', pending)
|
|
311
|
+
|
|
312
|
+
def content_schema(
|
|
313
|
+
self,
|
|
314
|
+
targets: Sequence[Any],
|
|
315
|
+
media_type: str,
|
|
316
|
+
mode: SchemaMode,
|
|
317
|
+
provider: Optional[SchemaProvider] = None,
|
|
318
|
+
) -> JsonSchema:
|
|
319
|
+
"""Schema of one media type; several targets become ``oneOf``."""
|
|
320
|
+
schemas = [
|
|
321
|
+
(
|
|
322
|
+
self._dialect.file_schema(target, media_type)
|
|
323
|
+
if isinstance(target, (File, FileList))
|
|
324
|
+
else self.resolve(target, mode, provider)
|
|
325
|
+
)
|
|
326
|
+
for target in targets
|
|
327
|
+
]
|
|
328
|
+
return schemas[0] if len(schemas) == 1 else {'oneOf': schemas}
|
|
329
|
+
|
|
330
|
+
def _provider_for(self, target: Any) -> SchemaProvider:
|
|
331
|
+
for provider in self._providers:
|
|
332
|
+
if provider.supports(target):
|
|
333
|
+
return provider
|
|
334
|
+
hint = (
|
|
335
|
+
' A list is not a schema: for alternatives use body_one_of(A, B), '
|
|
336
|
+
'responses(A, B) or responses={status: [A, B]}; for an array use list[A].'
|
|
337
|
+
if isinstance(target, (list, tuple))
|
|
338
|
+
else ' Install the pydantic extra, pass a raw JSON Schema dict or add a '
|
|
339
|
+
'SchemaProvider.'
|
|
340
|
+
)
|
|
341
|
+
raise UnsupportedSchemaError(
|
|
342
|
+
f'No schema provider supports {target!r}.{hint}',
|
|
343
|
+
target=target,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def _resolve_raw(self, schema: Mapping[str, Any], mode: SchemaMode) -> JsonSchema:
|
|
347
|
+
def walk(value: Any, path: tuple[str, ...]) -> Any:
|
|
348
|
+
if isinstance(value, (Ref, File, FileList, FormFields)):
|
|
349
|
+
try:
|
|
350
|
+
return self.resolve(value, mode)
|
|
351
|
+
except UnsupportedSchemaError as exc:
|
|
352
|
+
if exc.location:
|
|
353
|
+
raise
|
|
354
|
+
raise UnsupportedSchemaError(
|
|
355
|
+
f'{exc} (at {"/".join(path)})',
|
|
356
|
+
target=exc.target,
|
|
357
|
+
location=path,
|
|
358
|
+
) from None
|
|
359
|
+
if isinstance(value, Mapping):
|
|
360
|
+
items = cast('Mapping[str, Any]', value).items()
|
|
361
|
+
return {k: walk(v, (*path, str(k))) for k, v in items}
|
|
362
|
+
if isinstance(value, (list, tuple)):
|
|
363
|
+
values = cast('Sequence[Any]', value)
|
|
364
|
+
return [walk(v, (*path, str(i))) for i, v in enumerate(values)]
|
|
365
|
+
return value
|
|
366
|
+
|
|
367
|
+
return cast('JsonSchema', walk(schema, ()))
|
|
368
|
+
|
|
369
|
+
def _form(self, form: FormFields, mode: SchemaMode) -> JsonSchema:
|
|
370
|
+
schema: JsonSchema = {
|
|
371
|
+
'type': 'object',
|
|
372
|
+
'properties': {
|
|
373
|
+
name: self.resolve(value, mode) for name, value in form.fields
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
if form.required:
|
|
377
|
+
schema['required'] = list(form.required)
|
|
378
|
+
if form.description:
|
|
379
|
+
schema['description'] = form.description
|
|
380
|
+
return schema
|
|
381
|
+
|
|
382
|
+
def run(self) -> None:
|
|
383
|
+
"""Run providers until no placeholders are waiting (providers may nest)."""
|
|
384
|
+
while self._queue:
|
|
385
|
+
queue, self._queue = self._queue, []
|
|
386
|
+
by_provider: dict[int, list[_Pending]] = {}
|
|
387
|
+
for pending in queue:
|
|
388
|
+
by_provider.setdefault(id(pending.provider), []).append(pending)
|
|
389
|
+
for group in by_provider.values():
|
|
390
|
+
provider = group[0].provider
|
|
391
|
+
results = provider.generate([p.request for p in group], self)
|
|
392
|
+
if len(results) != len(group):
|
|
393
|
+
raise RuntimeError(
|
|
394
|
+
f'{type(provider).__name__}.generate returned {len(results)} '
|
|
395
|
+
f'schemas for {len(group)} requests',
|
|
396
|
+
)
|
|
397
|
+
for pending, result in zip(group, results):
|
|
398
|
+
pending.result = result
|
|
399
|
+
|
|
400
|
+
def fill(self, value: Any) -> Any:
|
|
401
|
+
"""Replace placeholders inside ``value`` in place; returns the filled value.
|
|
402
|
+
|
|
403
|
+
Provider results are deep-copied on insertion, so no two places in the
|
|
404
|
+
document share a mutable schema object.
|
|
405
|
+
"""
|
|
406
|
+
if isinstance(value, _Pending):
|
|
407
|
+
if value.result is None:
|
|
408
|
+
raise RuntimeError('schema placeholder was never resolved; call run()')
|
|
409
|
+
return self.fill(copy.deepcopy(value.result))
|
|
410
|
+
if isinstance(value, dict):
|
|
411
|
+
mapping = cast('JsonSchema', value)
|
|
412
|
+
for key, item in mapping.items():
|
|
413
|
+
mapping[key] = self.fill(item)
|
|
414
|
+
elif isinstance(value, list):
|
|
415
|
+
items = cast('list[object]', value)
|
|
416
|
+
for index, item in enumerate(items):
|
|
417
|
+
items[index] = self.fill(item)
|
|
418
|
+
return cast(Any, value)
|
|
419
|
+
|
|
420
|
+
def finish(self) -> None:
|
|
421
|
+
"""Run the providers and fill placeholders inside component schemas."""
|
|
422
|
+
self.run()
|
|
423
|
+
self.components.replace_all(
|
|
424
|
+
{
|
|
425
|
+
name: self.fill(schema)
|
|
426
|
+
for name, schema in self.components.as_dict().items()
|
|
427
|
+
},
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# --- built-in provider -------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
_SIMPLE: Mapping[Any, JsonSchema] = MappingProxyType(
|
|
434
|
+
{
|
|
435
|
+
str: {'type': 'string'},
|
|
436
|
+
int: {'type': 'integer'},
|
|
437
|
+
float: {'type': 'number'},
|
|
438
|
+
bool: {'type': 'boolean'},
|
|
439
|
+
bytes: {'type': 'string', 'format': 'binary'},
|
|
440
|
+
type(None): {'type': 'null'},
|
|
441
|
+
datetime.datetime: {'type': 'string', 'format': 'date-time'},
|
|
442
|
+
datetime.date: {'type': 'string', 'format': 'date'},
|
|
443
|
+
datetime.time: {'type': 'string', 'format': 'time'},
|
|
444
|
+
datetime.timedelta: {'type': 'string', 'format': 'duration'},
|
|
445
|
+
uuid.UUID: {'type': 'string', 'format': 'uuid'},
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
_UNION_TYPES: tuple[Any, ...] = (Union,)
|
|
450
|
+
if sys.version_info >= (3, 10): # pragma: no cover - depends on interpreter
|
|
451
|
+
import types
|
|
452
|
+
|
|
453
|
+
_UNION_TYPES = (Union, types.UnionType)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class BuiltinSchemas:
|
|
457
|
+
"""Plain Python types, enums and ``typing`` constructs, without Pydantic.
|
|
458
|
+
|
|
459
|
+
Used last: when Pydantic is installed it handles everything it supports.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
def supports(self, target: object) -> bool:
|
|
463
|
+
try:
|
|
464
|
+
hash(target)
|
|
465
|
+
except TypeError:
|
|
466
|
+
return False
|
|
467
|
+
if target is Any or target in _SIMPLE or target is decimal.Decimal:
|
|
468
|
+
return True
|
|
469
|
+
if is_class(target) and issubclass(target, enum.Enum):
|
|
470
|
+
return True
|
|
471
|
+
origin = get_origin(target)
|
|
472
|
+
return origin is not None and (
|
|
473
|
+
origin in _UNION_TYPES
|
|
474
|
+
or origin is Literal
|
|
475
|
+
or origin is Annotated
|
|
476
|
+
or origin in (list, set, frozenset, tuple, dict)
|
|
477
|
+
or origin in (Sequence, Mapping)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def generate(
|
|
481
|
+
self,
|
|
482
|
+
requests: Sequence[SchemaRequest],
|
|
483
|
+
context: SchemaContext,
|
|
484
|
+
) -> Sequence[JsonSchema]:
|
|
485
|
+
return [self._schema(r.target, r.mode, context) for r in requests]
|
|
486
|
+
|
|
487
|
+
def _schema(
|
|
488
|
+
self,
|
|
489
|
+
target: Any,
|
|
490
|
+
mode: SchemaMode,
|
|
491
|
+
context: SchemaContext,
|
|
492
|
+
) -> JsonSchema:
|
|
493
|
+
if target is Any:
|
|
494
|
+
return {}
|
|
495
|
+
if target in _SIMPLE:
|
|
496
|
+
return dict(_SIMPLE[target])
|
|
497
|
+
if target is decimal.Decimal:
|
|
498
|
+
if mode == 'serialization':
|
|
499
|
+
return {'type': 'string'}
|
|
500
|
+
return {'anyOf': [{'type': 'number'}, {'type': 'string'}]}
|
|
501
|
+
if is_class(target) and issubclass(target, enum.Enum):
|
|
502
|
+
return self._enum(target, context)
|
|
503
|
+
origin = get_origin(target)
|
|
504
|
+
args = get_args(target)
|
|
505
|
+
if origin is Annotated:
|
|
506
|
+
return context.resolve(args[0], mode)
|
|
507
|
+
if origin is Literal:
|
|
508
|
+
values = list(args)
|
|
509
|
+
if len(values) == 1:
|
|
510
|
+
return {'const': values[0], **_json_type(values)}
|
|
511
|
+
return {'enum': values, **_json_type(values)}
|
|
512
|
+
if origin in _UNION_TYPES:
|
|
513
|
+
return {'anyOf': [context.resolve(arg, mode) for arg in args]}
|
|
514
|
+
if origin is tuple:
|
|
515
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
516
|
+
return {'type': 'array', 'items': context.resolve(args[0], mode)}
|
|
517
|
+
return {
|
|
518
|
+
'type': 'array',
|
|
519
|
+
'prefixItems': [context.resolve(arg, mode) for arg in args],
|
|
520
|
+
'minItems': len(args),
|
|
521
|
+
'maxItems': len(args),
|
|
522
|
+
}
|
|
523
|
+
if origin in (dict, Mapping):
|
|
524
|
+
value_type = args[1] if len(args) == 2 else Any
|
|
525
|
+
return {
|
|
526
|
+
'type': 'object',
|
|
527
|
+
'additionalProperties': context.resolve(value_type, mode),
|
|
528
|
+
}
|
|
529
|
+
schema: JsonSchema = {'type': 'array'}
|
|
530
|
+
if args:
|
|
531
|
+
schema['items'] = context.resolve(args[0], mode)
|
|
532
|
+
if origin in (set, frozenset):
|
|
533
|
+
schema['uniqueItems'] = True
|
|
534
|
+
return schema
|
|
535
|
+
|
|
536
|
+
@staticmethod
|
|
537
|
+
def _enum(target: type[enum.Enum], context: SchemaContext) -> JsonSchema:
|
|
538
|
+
values = [member.value for member in target]
|
|
539
|
+
schema: JsonSchema = {
|
|
540
|
+
'enum': values,
|
|
541
|
+
'title': target.__name__,
|
|
542
|
+
**_json_type(values),
|
|
543
|
+
}
|
|
544
|
+
return context.add_component(target.__name__, schema, 'BuiltinSchemas')
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _json_type(values: Sequence[Any]) -> JsonSchema:
|
|
548
|
+
if values and all(isinstance(v, str) for v in values):
|
|
549
|
+
return {'type': 'string'}
|
|
550
|
+
if values and all(isinstance(v, bool) for v in values):
|
|
551
|
+
return {'type': 'boolean'}
|
|
552
|
+
if values and all(isinstance(v, int) and not isinstance(v, bool) for v in values):
|
|
553
|
+
return {'type': 'integer'}
|
|
554
|
+
return {}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Where operations come from: route sources and webhook sets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable, Optional, Protocol, TypeVar, Union
|
|
8
|
+
|
|
9
|
+
from qstd_openapi import openapi
|
|
10
|
+
from qstd_openapi.meta.model import Parameter
|
|
11
|
+
|
|
12
|
+
F = TypeVar('F', bound=Callable[..., Any])
|
|
13
|
+
|
|
14
|
+
HTTP_METHODS = ('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RouteEntry:
|
|
19
|
+
"""One ``(path, method)`` served by ``handler``.
|
|
20
|
+
|
|
21
|
+
``tags`` and ``parameters`` are defaults known to the source (a router
|
|
22
|
+
name, path parameters parsed from the URL template); explicit metadata on
|
|
23
|
+
the handler overrides them. ``name`` is the route's name in the framework
|
|
24
|
+
(unique in the application), used for the default ``operationId``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
path: str
|
|
28
|
+
method: str
|
|
29
|
+
handler: Any
|
|
30
|
+
tags: tuple[str, ...] = ()
|
|
31
|
+
parameters: tuple[Parameter, ...] = ()
|
|
32
|
+
name: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if not self.path.startswith('/'):
|
|
36
|
+
raise ValueError(f'Path must start with "/": {self.path!r}')
|
|
37
|
+
method = self.method.lower()
|
|
38
|
+
if method not in HTTP_METHODS:
|
|
39
|
+
raise ValueError(f'Unsupported HTTP method {self.method!r}')
|
|
40
|
+
object.__setattr__(self, 'method', method)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class WebhookEntry:
|
|
45
|
+
"""A function that sends a webhook; name and method come from its metadata."""
|
|
46
|
+
|
|
47
|
+
handler: Any
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
SourceEntry = Union[RouteEntry, WebhookEntry]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OperationSource(Protocol):
|
|
54
|
+
def collect(self) -> Iterable[SourceEntry]: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Routes:
|
|
58
|
+
"""Routes listed by hand: ``Routes(('/users', 'post', register_user), ...)``."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, *routes: Union[RouteEntry, tuple[str, str, Any]]) -> None:
|
|
61
|
+
self._routes = tuple(
|
|
62
|
+
route if isinstance(route, RouteEntry) else RouteEntry(*route)
|
|
63
|
+
for route in routes
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def collect(self) -> Iterable[SourceEntry]:
|
|
67
|
+
return self._routes
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WebhookSet:
|
|
71
|
+
"""Webhooks of one module, included into a document explicitly.
|
|
72
|
+
|
|
73
|
+
Usage::
|
|
74
|
+
|
|
75
|
+
webhooks = WebhookSet()
|
|
76
|
+
|
|
77
|
+
@webhooks.register('user.registered', scope=ApiScope.CLIENT_API)
|
|
78
|
+
@openapi.body(UserRegisteredEvent)
|
|
79
|
+
async def send_user_registered(user): ...
|
|
80
|
+
|
|
81
|
+
spec.include(webhooks)
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, *handlers: Any) -> None:
|
|
85
|
+
self._handlers: list[Any] = list(handlers)
|
|
86
|
+
|
|
87
|
+
def add(self, handler: F) -> F:
|
|
88
|
+
"""Include a function already marked with ``openapi.webhook``."""
|
|
89
|
+
if handler not in self._handlers:
|
|
90
|
+
self._handlers.append(handler)
|
|
91
|
+
return handler
|
|
92
|
+
|
|
93
|
+
def register(
|
|
94
|
+
self,
|
|
95
|
+
name: str,
|
|
96
|
+
method: str = 'post',
|
|
97
|
+
*,
|
|
98
|
+
scope: Optional[Union[Hashable, Iterable[Hashable]]] = None,
|
|
99
|
+
) -> Callable[[F], F]:
|
|
100
|
+
def decorate(handler: F) -> F:
|
|
101
|
+
openapi.webhook(name, method, scope=scope)(handler)
|
|
102
|
+
return self.add(handler)
|
|
103
|
+
|
|
104
|
+
return decorate
|
|
105
|
+
|
|
106
|
+
def collect(self) -> Iterable[SourceEntry]:
|
|
107
|
+
return tuple(WebhookEntry(handler) for handler in self._handlers)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from qstd_openapi.dialects.base import OpenAPIDialect
|
|
4
|
+
from qstd_openapi.dialects.openapi30 import OpenAPI30
|
|
5
|
+
from qstd_openapi.dialects.openapi31 import OpenAPI31
|
|
6
|
+
|
|
7
|
+
__all__ = ('VERSIONS', 'OpenAPI30', 'OpenAPI31', 'OpenAPIDialect', 'dialect_for')
|
|
8
|
+
|
|
9
|
+
VERSIONS = ('3.0', '3.1')
|
|
10
|
+
"""Versions :func:`dialect_for` accepts."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def dialect_for(version: str) -> OpenAPIDialect:
|
|
14
|
+
"""The dialect for ``'3.0'`` or ``'3.1'`` (command line, configuration)."""
|
|
15
|
+
if version == '3.0':
|
|
16
|
+
return OpenAPI30()
|
|
17
|
+
if version == '3.1':
|
|
18
|
+
return OpenAPI31()
|
|
19
|
+
raise ValueError(f'Unsupported OpenAPI version {version!r}: use 3.0 or 3.1')
|