qstd-openapi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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')