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,296 @@
1
+ """Including ready OpenAPI documents (``aggregate`` mode).
2
+
3
+ ``Document`` turns a raw document into a contribution: components are
4
+ renamed into a namespace, every local ``$ref`` is rewritten, paths get a
5
+ prefix and the document's root ``security`` moves into its operations.
6
+ The builder then merges contributions in include order; collisions go
7
+ through the inclusion's conflict policy.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import importlib
14
+ import json
15
+
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Literal, Optional, Protocol, Union, cast
20
+
21
+ from qstd_openapi.core.sources import HTTP_METHODS
22
+ from qstd_openapi.errors import UnsupportedDocumentError
23
+
24
+ JsonObject = dict[str, Any]
25
+ ConflictAction = Literal['error', 'keep', 'replace']
26
+
27
+ _IGNORED_ROOT = ('info', 'servers', 'externalDocs', 'jsonSchemaDialect')
28
+ _HANDLED_ROOT = ('openapi', 'paths', 'components', 'tags', 'security', *_IGNORED_ROOT)
29
+ _UNNAMESPACED_SECTIONS = frozenset({'securitySchemes'})
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Conflict:
34
+ """Two sources define the same thing differently."""
35
+
36
+ kind: str
37
+ """``operation``, ``webhook``, ``path-item``, ``component``, ``tag`` or ``root``."""
38
+ key: str
39
+ existing: Any
40
+ incoming: Any
41
+ existing_origin: str
42
+ incoming_origin: str
43
+
44
+ def describe(self) -> str:
45
+ return (
46
+ f'{self.kind} {self.key} is defined by both {self.existing_origin} '
47
+ f'and {self.incoming_origin}'
48
+ )
49
+
50
+
51
+ class ConflictPolicy(Protocol):
52
+ """Decides each conflict of an included document.
53
+
54
+ **Provisional:** the signature may change before 1.0; the string policies
55
+ (``'error'``, ``'keep'``, ``'replace'``) are stable.
56
+ """
57
+
58
+ def __call__(self, conflict: Conflict) -> ConflictAction:
59
+ """``'keep'`` the existing value, ``'replace'`` it or ``'error'``."""
60
+ ...
61
+
62
+
63
+ @dataclass
64
+ class DocumentContribution:
65
+ origin: str
66
+ policy: Union[ConflictAction, ConflictPolicy]
67
+ paths: JsonObject = field(default_factory=lambda: JsonObject())
68
+ webhooks: JsonObject = field(default_factory=lambda: JsonObject())
69
+ components: dict[str, JsonObject] = field(
70
+ default_factory=lambda: dict[str, JsonObject](),
71
+ )
72
+ tags: list[JsonObject] = field(default_factory=lambda: list[JsonObject]())
73
+ extensions: JsonObject = field(default_factory=lambda: JsonObject())
74
+ notes: list[tuple[str, str, str]] = field(
75
+ default_factory=lambda: list[tuple[str, str, str]](),
76
+ )
77
+ """``(code, level, message)`` diagnostics."""
78
+
79
+ def decide(self, conflict: Conflict) -> ConflictAction:
80
+ action = self.policy if isinstance(self.policy, str) else self.policy(conflict)
81
+ if action not in ('error', 'keep', 'replace'):
82
+ raise ValueError(f'Conflict policy returned {action!r}')
83
+ return action
84
+
85
+
86
+ class DocumentChecker(Protocol):
87
+ """The part of :class:`OpenAPIDialect` needed to read a foreign document."""
88
+
89
+ def check_document(self, document: Mapping[str, Any]) -> None: ...
90
+
91
+ def extract_webhooks(self, document: JsonObject) -> JsonObject: ...
92
+
93
+
94
+ def _decode(token: str) -> str:
95
+ return token.replace('~1', '/').replace('~0', '~')
96
+
97
+
98
+ def _encode(token: str) -> str:
99
+ return token.replace('~', '~0').replace('/', '~1')
100
+
101
+
102
+ class Document:
103
+ """An existing OpenAPI document included into another one.
104
+
105
+ ``document`` is a mapping, or a callable returning one (loaded at build
106
+ time). Use :meth:`from_file` for JSON/YAML files. The input is never
107
+ modified.
108
+
109
+ - ``path_prefix`` is prepended to every path;
110
+ - ``component_namespace`` renames components to ``namespace_format``
111
+ (``'{namespace}.{name}'``: ``User`` → ``profiles.User``) in every section
112
+ except ``securitySchemes``, which are usually shared between services;
113
+ - ``on_conflict``: ``'error'`` (default), ``'keep'`` the earlier
114
+ definition, ``'replace'`` it, or a :class:`ConflictPolicy` (provisional).
115
+
116
+ The document's ``info``, ``servers``, ``externalDocs`` and
117
+ ``jsonSchemaDialect`` are ignored (the including document has its own);
118
+ its root ``security`` is copied into operations without their own.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ document: Union[Mapping[str, Any], Callable[[], Mapping[str, Any]]],
124
+ *,
125
+ origin: str,
126
+ path_prefix: str = '',
127
+ component_namespace: Optional[str] = None,
128
+ namespace_format: str = '{namespace}.{name}',
129
+ on_conflict: Union[ConflictAction, ConflictPolicy] = 'error',
130
+ ) -> None:
131
+ if path_prefix and (
132
+ not path_prefix.startswith('/') or path_prefix.endswith('/')
133
+ ):
134
+ raise ValueError(
135
+ f'path_prefix must start and not end with "/": {path_prefix!r}',
136
+ )
137
+ self.origin = origin
138
+ self.path_prefix = path_prefix
139
+ self.component_namespace = component_namespace
140
+ self.namespace_format = namespace_format
141
+ self.on_conflict: Union[ConflictAction, ConflictPolicy] = on_conflict
142
+ if callable(document):
143
+ self._load: Callable[[], Mapping[str, Any]] = document
144
+ else:
145
+ snapshot = copy.deepcopy(dict(document))
146
+ self._check_structure(snapshot)
147
+ self._load = lambda: snapshot
148
+
149
+ @classmethod
150
+ def from_file(cls, path: Union[str, Path], **options: Any) -> Document:
151
+ """Load a ``.json`` or ``.yaml``/``.yml`` file (YAML needs PyYAML)."""
152
+ file = Path(path)
153
+ text = file.read_text(encoding='utf-8')
154
+ if file.suffix in ('.yaml', '.yml'):
155
+ yaml: Any = importlib.import_module('yaml')
156
+ loaded: Any = yaml.safe_load(text)
157
+ else:
158
+ loaded = json.loads(text)
159
+ options.setdefault('origin', str(file))
160
+ return cls(cast('Mapping[str, Any]', loaded), **options)
161
+
162
+ def collect(self) -> tuple[()]:
163
+ """A document contributes no operations of its own (see :meth:`contribution`)."""
164
+ return ()
165
+
166
+ # --- contribution ---------------------------------------------------
167
+
168
+ def contribution(self, checker: DocumentChecker) -> DocumentContribution:
169
+ document = copy.deepcopy(dict(self._load()))
170
+ self._check_structure(document)
171
+ try:
172
+ checker.check_document(document)
173
+ except ValueError as exc:
174
+ raise UnsupportedDocumentError(f'{self.origin}: {exc}') from exc
175
+
176
+ result = DocumentContribution(self.origin, self.on_conflict)
177
+ renames = self._renames(
178
+ cast('Mapping[str, Any]', document.get('components', {})),
179
+ )
180
+ self._rewrite(document, renames, result)
181
+ root_security = document.get('security')
182
+
183
+ for path, item in cast(
184
+ 'Mapping[str, JsonObject]',
185
+ document.get('paths', {}),
186
+ ).items():
187
+ if root_security is not None:
188
+ self._push_security(item, root_security)
189
+ result.paths[self._prefixed(path)] = item
190
+ for name, item in checker.extract_webhooks(document).items():
191
+ if root_security is not None:
192
+ self._push_security(cast('JsonObject', item), root_security)
193
+ result.webhooks[name] = item
194
+
195
+ for section, entries in cast(
196
+ 'Mapping[str, JsonObject]',
197
+ document.get('components', {}),
198
+ ).items():
199
+ result.components[section] = {
200
+ renames.get((section, name), name): value
201
+ for name, value in entries.items()
202
+ }
203
+ result.tags = list(cast('list[JsonObject]', document.get('tags', [])))
204
+ for key, value in document.items():
205
+ if key in _HANDLED_ROOT:
206
+ continue
207
+ result.extensions[key] = value
208
+ for key in _IGNORED_ROOT:
209
+ if key in document:
210
+ result.notes.append(
211
+ (
212
+ 'ignored-root-field',
213
+ 'info',
214
+ f'{self.origin}: root {key!r} is not merged',
215
+ ),
216
+ )
217
+ return result
218
+
219
+ @staticmethod
220
+ def _check_structure(document: Mapping[str, Any]) -> None:
221
+ if not isinstance(document.get('openapi'), str):
222
+ raise UnsupportedDocumentError(
223
+ 'an OpenAPI document needs a string "openapi" field',
224
+ )
225
+ for key in ('paths', 'components'):
226
+ if key in document and not isinstance(document[key], Mapping):
227
+ raise UnsupportedDocumentError(f'"{key}" must be an object')
228
+
229
+ def _renames(self, components: Mapping[str, Any]) -> dict[tuple[str, str], str]:
230
+ if not self.component_namespace:
231
+ return {}
232
+ return {
233
+ (section, name): self.namespace_format.format(
234
+ namespace=self.component_namespace,
235
+ name=name,
236
+ )
237
+ for section, entries in components.items()
238
+ if section not in _UNNAMESPACED_SECTIONS
239
+ for name in cast('Mapping[str, Any]', entries)
240
+ }
241
+
242
+ def _prefixed(self, path: str) -> str:
243
+ return f'{self.path_prefix}{path}' if self.path_prefix else path
244
+
245
+ def _rewrite_ref(self, ref: str, renames: Mapping[tuple[str, str], str]) -> str:
246
+ tokens = [_decode(token) for token in ref[2:].split('/')]
247
+ if len(tokens) >= 3 and tokens[0] == 'components':
248
+ tokens[2] = renames.get((tokens[1], tokens[2]), tokens[2])
249
+ elif len(tokens) >= 2 and tokens[0] == 'paths':
250
+ tokens[1] = self._prefixed(tokens[1])
251
+ return '#/' + '/'.join(_encode(token) for token in tokens)
252
+
253
+ def _rewrite(
254
+ self,
255
+ value: Any,
256
+ renames: Mapping[tuple[str, str], str],
257
+ result: DocumentContribution,
258
+ ) -> None:
259
+ if isinstance(value, list):
260
+ for item in cast('list[object]', value):
261
+ self._rewrite(item, renames, result)
262
+ return
263
+ if not isinstance(value, dict):
264
+ return
265
+ mapping = cast('JsonObject', value)
266
+ ref = mapping.get('$ref')
267
+ if isinstance(ref, str):
268
+ if ref.startswith('#/'):
269
+ mapping['$ref'] = self._rewrite_ref(ref, renames)
270
+ else:
271
+ result.notes.append(
272
+ (
273
+ 'external-ref',
274
+ 'warning',
275
+ f'{self.origin}: external $ref {ref!r} is kept as is',
276
+ ),
277
+ )
278
+ discriminator = mapping.get('discriminator')
279
+ if isinstance(discriminator, dict):
280
+ targets = cast('JsonObject', discriminator).get('mapping')
281
+ if isinstance(targets, dict):
282
+ for key, target in cast('JsonObject', targets).items():
283
+ if isinstance(target, str) and target.startswith('#/'):
284
+ cast('JsonObject', targets)[key] = self._rewrite_ref(
285
+ target,
286
+ renames,
287
+ )
288
+ for item in mapping.values():
289
+ self._rewrite(item, renames, result)
290
+
291
+ @staticmethod
292
+ def _push_security(item: JsonObject, security: Any) -> None:
293
+ for method in HTTP_METHODS:
294
+ operation = item.get(method)
295
+ if isinstance(operation, dict) and 'security' not in operation:
296
+ cast('JsonObject', operation)['security'] = copy.deepcopy(security)
@@ -0,0 +1,98 @@
1
+ """Error objects (``errors=[...]``) turned into responses by error providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, Optional, Protocol
8
+
9
+ from qstd_openapi.core.schemas import JsonSchema, SchemaContext, SchemaRequest
10
+ from qstd_openapi.errors import UnsupportedErrorObjectError, UnsupportedSchemaError
11
+ from qstd_openapi.meta.model import StatusCode
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ErrorResponse:
16
+ status: StatusCode
17
+ schema: Any
18
+ """Anything the schema providers understand, usually a raw dict with ``Ref``."""
19
+ name: Optional[str] = None
20
+ """Component name; ``None`` keeps the schema inline."""
21
+ description: Optional[str] = None
22
+
23
+
24
+ class ErrorProvider(Protocol):
25
+ def supports(self, error: object) -> bool: ...
26
+
27
+ def describe(self, error: object) -> ErrorResponse: ...
28
+
29
+
30
+ class ErrorSchemas:
31
+ """Adapts error providers to the schema provider protocol.
32
+
33
+ Error classes become components, so an error referenced from another
34
+ error's field (a validation error listing item errors) is a ``$ref``.
35
+ """
36
+
37
+ def __init__(self, providers: Sequence[ErrorProvider]) -> None:
38
+ self._providers = tuple(providers)
39
+
40
+ def provider_for(self, error: object) -> Optional[ErrorProvider]:
41
+ for provider in self._providers:
42
+ if provider.supports(error):
43
+ return provider
44
+ return None
45
+
46
+ def describe(self, error: object) -> ErrorResponse:
47
+ provider = self.provider_for(error)
48
+ if provider is None:
49
+ raise UnsupportedErrorObjectError(
50
+ f'No error provider supports {error!r}; pass errors=[...] to OpenAPI '
51
+ '(for example AppErrors from qstd_openapi.contrib.app_errors).',
52
+ )
53
+ return provider.describe(error)
54
+
55
+ def supports(self, target: object) -> bool:
56
+ return self.provider_for(target) is not None
57
+
58
+ def generate(
59
+ self,
60
+ requests: Sequence[SchemaRequest],
61
+ context: SchemaContext,
62
+ ) -> Sequence[JsonSchema]:
63
+ results: list[JsonSchema] = []
64
+ for request in requests:
65
+ response = self.describe(request.target)
66
+ try:
67
+ schema = context.resolve(response.schema, 'serialization')
68
+ except UnsupportedSchemaError as exc:
69
+ raise _unsupported_field(request.target, exc) from None
70
+ if response.name:
71
+ origin = f'error provider for {request.target!r}'
72
+ schema = context.add_component(response.name, schema, origin)
73
+ results.append(schema)
74
+ return results
75
+
76
+
77
+ def _unsupported_field(
78
+ error: object,
79
+ exc: UnsupportedSchemaError,
80
+ ) -> UnsupportedSchemaError:
81
+ name = getattr(error, '__qualname__', None) or repr(error)
82
+ location = exc.location
83
+ if len(location) == 2 and location[0] == 'properties':
84
+ where = f'field {location[1]!r}'
85
+ hint = (
86
+ ' If the field is not part of the error response, keep it out of the '
87
+ f'schema: AppErrors skips private (_{location[1]}) and ClassVar fields; '
88
+ 'for another rule subclass AppErrors or implement ErrorProvider.'
89
+ )
90
+ else:
91
+ where = '/'.join(location) or 'schema'
92
+ hint = ''
93
+ return UnsupportedSchemaError(
94
+ f'Cannot describe error {name}: no schema provider supports '
95
+ f'{exc.target!r} in its {where}.{hint}',
96
+ target=exc.target,
97
+ location=exc.location,
98
+ )
@@ -0,0 +1,81 @@
1
+ """Which operations go into a document and which tags they get."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable, Iterable
6
+ from dataclasses import dataclass
7
+ from typing import Optional, Protocol
8
+
9
+ from qstd_openapi.meta.model import OperationMeta
10
+
11
+
12
+ @dataclass(frozen=True, init=False)
13
+ class ScopeFilter:
14
+ """Select operations by their ``scope`` labels.
15
+
16
+ - ``include``: an operation with scopes is kept if it has at least one of
17
+ them; ``None`` keeps every scoped operation;
18
+ - ``exclude``: an operation with any of these scopes is dropped;
19
+ - ``unscoped``: whether operations without scopes are kept.
20
+ """
21
+
22
+ include: Optional[frozenset[Hashable]] = None
23
+ exclude: frozenset[Hashable] = frozenset()
24
+ unscoped: bool = True
25
+
26
+ def __init__(
27
+ self,
28
+ include: Optional[Iterable[Hashable]] = None,
29
+ exclude: Iterable[Hashable] = (),
30
+ *,
31
+ unscoped: bool = True,
32
+ ) -> None:
33
+ object.__setattr__(
34
+ self,
35
+ 'include',
36
+ frozenset(include) if include is not None else None,
37
+ )
38
+ object.__setattr__(self, 'exclude', frozenset(exclude))
39
+ object.__setattr__(self, 'unscoped', unscoped)
40
+
41
+ def allows(self, scopes: Iterable[Hashable]) -> bool:
42
+ labels = set(scopes)
43
+ if not labels:
44
+ return self.unscoped
45
+ if labels & self.exclude:
46
+ return False
47
+ return self.include is None or bool(labels & self.include)
48
+
49
+
50
+ class TagRule(Protocol):
51
+ """Extra tags for an operation; ``path`` is ``None`` for webhooks."""
52
+
53
+ def __call__(
54
+ self,
55
+ path: Optional[str],
56
+ method: str,
57
+ meta: OperationMeta,
58
+ ) -> Iterable[str]: ...
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class PathTag:
63
+ """Tag operations whose path contains ``includes`` and not ``excludes``."""
64
+
65
+ name: str
66
+ includes: Optional[str] = None
67
+ excludes: Optional[str] = None
68
+
69
+ def __call__(
70
+ self,
71
+ path: Optional[str],
72
+ method: str, # noqa: ARG002
73
+ meta: OperationMeta, # noqa: ARG002
74
+ ) -> Iterable[str]:
75
+ if path is None:
76
+ return ()
77
+ if self.includes is not None and self.includes not in path:
78
+ return ()
79
+ if self.excludes is not None and self.excludes in path:
80
+ return ()
81
+ return (self.name,)