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,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