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
qstd_openapi/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Framework-agnostic, declarative OpenAPI documentation."""
|
|
2
|
+
|
|
3
|
+
from qstd_openapi import markers, openapi
|
|
4
|
+
from qstd_openapi.core import (
|
|
5
|
+
BuildResult,
|
|
6
|
+
Conflict,
|
|
7
|
+
Diagnostic,
|
|
8
|
+
Document,
|
|
9
|
+
ErrorProvider,
|
|
10
|
+
ErrorResponse,
|
|
11
|
+
OpenAPI,
|
|
12
|
+
PathTag,
|
|
13
|
+
RouteEntry,
|
|
14
|
+
Routes,
|
|
15
|
+
ScopeFilter,
|
|
16
|
+
TypeOverride,
|
|
17
|
+
WebhookSet,
|
|
18
|
+
validate_document,
|
|
19
|
+
)
|
|
20
|
+
from qstd_openapi.errors import (
|
|
21
|
+
AttachError,
|
|
22
|
+
BuildError,
|
|
23
|
+
QstdOpenAPIError,
|
|
24
|
+
ScalarConflictError,
|
|
25
|
+
)
|
|
26
|
+
from qstd_openapi.meta import OperationMeta, read_operation
|
|
27
|
+
from qstd_openapi.serialization import dumps, dumps_yaml
|
|
28
|
+
from qstd_openapi.tags import tags_from_markdown
|
|
29
|
+
|
|
30
|
+
__all__ = (
|
|
31
|
+
'AttachError',
|
|
32
|
+
'BuildError',
|
|
33
|
+
'BuildResult',
|
|
34
|
+
'Conflict',
|
|
35
|
+
'Diagnostic',
|
|
36
|
+
'Document',
|
|
37
|
+
'ErrorProvider',
|
|
38
|
+
'ErrorResponse',
|
|
39
|
+
'OpenAPI',
|
|
40
|
+
'OperationMeta',
|
|
41
|
+
'PathTag',
|
|
42
|
+
'QstdOpenAPIError',
|
|
43
|
+
'RouteEntry',
|
|
44
|
+
'Routes',
|
|
45
|
+
'ScalarConflictError',
|
|
46
|
+
'ScopeFilter',
|
|
47
|
+
'TypeOverride',
|
|
48
|
+
'WebhookSet',
|
|
49
|
+
'dumps',
|
|
50
|
+
'dumps_yaml',
|
|
51
|
+
'markers',
|
|
52
|
+
'openapi',
|
|
53
|
+
'read_operation',
|
|
54
|
+
'tags_from_markdown',
|
|
55
|
+
'validate_document',
|
|
56
|
+
)
|
qstd_openapi/__main__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Command line: ``python -m qstd_openapi dump package.module:spec``.
|
|
2
|
+
|
|
3
|
+
``TARGET`` is ``module:attribute`` (dotted attributes allowed); the attribute
|
|
4
|
+
is an ``OpenAPI`` instance or a function without arguments returning one —
|
|
5
|
+
the place to create the application and include its routes.
|
|
6
|
+
|
|
7
|
+
Options: ``-o FILE`` (stdout by default), ``--yaml`` (also chosen by a
|
|
8
|
+
``.yaml``/``.yml`` file name), ``--dialect`` (a version accepted by
|
|
9
|
+
``qstd_openapi.dialects.dialect_for``), and ``--check`` (compare with ``FILE``
|
|
10
|
+
instead of writing it; exit code 1 and a diff on mismatch).
|
|
11
|
+
Errors while loading or building exit with code 2.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import importlib
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
from qstd_openapi.core.document import OpenAPI
|
|
25
|
+
from qstd_openapi.dialects import VERSIONS, dialect_for
|
|
26
|
+
from qstd_openapi.errors import QstdOpenAPIError
|
|
27
|
+
from qstd_openapi.testing import diff, render
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load(target: str) -> OpenAPI:
|
|
31
|
+
"""Resolve ``module:attribute`` to an ``OpenAPI`` (calling a factory)."""
|
|
32
|
+
module_name, _, attribute = target.partition(':')
|
|
33
|
+
if not module_name or not attribute:
|
|
34
|
+
raise ValueError(f'Expected module:attribute, got {target!r}')
|
|
35
|
+
if os.getcwd() not in sys.path:
|
|
36
|
+
sys.path.insert(0, os.getcwd())
|
|
37
|
+
value: Any = importlib.import_module(module_name)
|
|
38
|
+
for part in attribute.split('.'):
|
|
39
|
+
value = getattr(value, part)
|
|
40
|
+
if not isinstance(value, OpenAPI) and callable(value):
|
|
41
|
+
value = value()
|
|
42
|
+
if not isinstance(value, OpenAPI):
|
|
43
|
+
raise TypeError(f'{target} is {type(value).__name__}, not OpenAPI')
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parser() -> argparse.ArgumentParser:
|
|
48
|
+
parser = argparse.ArgumentParser(prog='python -m qstd_openapi')
|
|
49
|
+
commands = parser.add_subparsers(dest='command', required=True)
|
|
50
|
+
dump = commands.add_parser('dump', help='build a document and write it')
|
|
51
|
+
dump.add_argument('target', help='module:attribute — OpenAPI or a factory')
|
|
52
|
+
dump.add_argument('-o', '--output', help='file to write (default: stdout)')
|
|
53
|
+
dump.add_argument('--yaml', action='store_true', help='YAML instead of JSON')
|
|
54
|
+
dump.add_argument('--dialect', choices=VERSIONS, help='OpenAPI version')
|
|
55
|
+
dump.add_argument(
|
|
56
|
+
'--check',
|
|
57
|
+
action='store_true',
|
|
58
|
+
help='compare with --output instead of writing; exit 1 on difference',
|
|
59
|
+
)
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
64
|
+
args = _parser().parse_args(argv)
|
|
65
|
+
output = Path(args.output) if args.output else None
|
|
66
|
+
if args.check and output is None:
|
|
67
|
+
sys.stderr.write('error: --check requires -o FILE\n')
|
|
68
|
+
return 2
|
|
69
|
+
yaml = bool(args.yaml) or (
|
|
70
|
+
output is not None and output.suffix in ('.yaml', '.yml')
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
spec = load(args.target)
|
|
74
|
+
if args.dialect:
|
|
75
|
+
spec = spec.derive(dialect=dialect_for(args.dialect))
|
|
76
|
+
text = render(spec, yaml=yaml)
|
|
77
|
+
except (
|
|
78
|
+
QstdOpenAPIError,
|
|
79
|
+
ImportError,
|
|
80
|
+
AttributeError,
|
|
81
|
+
TypeError,
|
|
82
|
+
ValueError,
|
|
83
|
+
) as exc:
|
|
84
|
+
sys.stderr.write(f'error: {exc}\n')
|
|
85
|
+
return 2
|
|
86
|
+
if args.check:
|
|
87
|
+
assert output is not None
|
|
88
|
+
expected = output.read_text(encoding='utf-8') if output.exists() else ''
|
|
89
|
+
if expected != text:
|
|
90
|
+
sys.stderr.write(f'{output} is out of date\n')
|
|
91
|
+
sys.stderr.write(diff(expected, text, name=str(output)))
|
|
92
|
+
return 1
|
|
93
|
+
return 0
|
|
94
|
+
if output is None:
|
|
95
|
+
sys.stdout.write(text)
|
|
96
|
+
else:
|
|
97
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
output.write_text(text, encoding='utf-8')
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == '__main__': # pragma: no cover - entry point
|
|
103
|
+
sys.exit(main())
|
qstd_openapi/_compat.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Python version differences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, get_origin
|
|
6
|
+
|
|
7
|
+
from typing_extensions import TypeGuard
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_class(obj: object) -> TypeGuard[type[Any]]:
|
|
11
|
+
"""``isinstance(obj, type)`` without parametrized generics.
|
|
12
|
+
|
|
13
|
+
On Python 3.9 and 3.10 ``isinstance(list[int], type)`` is ``True``, and a
|
|
14
|
+
following ``issubclass`` against an ABC raises ``TypeError``.
|
|
15
|
+
"""
|
|
16
|
+
return isinstance(obj, type) and get_origin(obj) is None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional providers for common application conventions."""
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Error provider for "static error classes".
|
|
2
|
+
|
|
3
|
+
The convention: an application error class has a unique integer ``code``,
|
|
4
|
+
a ``message`` (usually constant) and public annotated payload fields, and is
|
|
5
|
+
serialized as ``{"code": ..., "error": <class name>, "message": ..., **payload}``.
|
|
6
|
+
The HTTP status comes from the project (a function, a class attribute or a
|
|
7
|
+
mapping of base classes). Nothing here imports project code.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from qstd_openapi.contrib.app_errors import AppErrors
|
|
12
|
+
|
|
13
|
+
OpenAPI(..., errors=[AppErrors(base=ApplicationError, status=get_http_status)])
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from typing import (
|
|
22
|
+
Any,
|
|
23
|
+
Callable,
|
|
24
|
+
ClassVar,
|
|
25
|
+
Optional,
|
|
26
|
+
cast,
|
|
27
|
+
get_origin,
|
|
28
|
+
get_type_hints,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from qstd_openapi._compat import is_class
|
|
32
|
+
from qstd_openapi.core.error_responses import ErrorResponse
|
|
33
|
+
from qstd_openapi.core.schemas import JsonSchema
|
|
34
|
+
from qstd_openapi.errors import ErrorAnnotationError
|
|
35
|
+
from qstd_openapi.markers import Ref
|
|
36
|
+
|
|
37
|
+
__all__ = ('AppErrors',)
|
|
38
|
+
|
|
39
|
+
MESSAGE_MODE_ATTR = '__openapi_message__'
|
|
40
|
+
"""Class attribute forcing the message schema: ``'const'`` or ``'dynamic'``."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AppErrors:
|
|
44
|
+
"""Describe application error classes derived from ``base``.
|
|
45
|
+
|
|
46
|
+
Status resolution, first match wins:
|
|
47
|
+
|
|
48
|
+
1. ``status`` — a callable ``(error_class) -> int`` (use the same function
|
|
49
|
+
the exception handler uses, so the document cannot drift);
|
|
50
|
+
2. the class attribute named ``status_attr`` (``status_code``), if set;
|
|
51
|
+
3. ``status_by_class`` — the nearest base class in the MRO;
|
|
52
|
+
4. ``default_status``.
|
|
53
|
+
|
|
54
|
+
The message is a ``const`` when the class defines a constant one; it is a
|
|
55
|
+
plain string when it is missing, contains ``{`` (a template filled in at
|
|
56
|
+
runtime) or the class sets ``__openapi_message__ = 'dynamic'``.
|
|
57
|
+
|
|
58
|
+
Payload fields are the public annotated attributes over the MRO; names
|
|
59
|
+
starting with ``_`` and ``ClassVar`` annotations are skipped, so the
|
|
60
|
+
schema follows a ``to_dict()`` built by the same rule.
|
|
61
|
+
|
|
62
|
+
Every step is a public method a subclass may override: ``status_for``,
|
|
63
|
+
``schema_for`` (the whole schema), ``payload_fields`` (which fields and
|
|
64
|
+
their types), ``code_schema`` and ``message_schema``. For a different
|
|
65
|
+
convention altogether implement ``ErrorProvider`` instead.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
base: type,
|
|
71
|
+
*,
|
|
72
|
+
status: Optional[Callable[[type], int]] = None,
|
|
73
|
+
status_attr: Optional[str] = 'status_code',
|
|
74
|
+
status_by_class: Optional[Mapping[Any, int]] = None,
|
|
75
|
+
default_status: int = 500,
|
|
76
|
+
code_field: str = 'code',
|
|
77
|
+
message_field: str = 'message',
|
|
78
|
+
name_field: str = 'error',
|
|
79
|
+
) -> None:
|
|
80
|
+
self.base = base
|
|
81
|
+
self._status = status
|
|
82
|
+
self._status_attr = status_attr
|
|
83
|
+
self._status_by_class = dict(status_by_class or {})
|
|
84
|
+
self._default_status = default_status
|
|
85
|
+
self._code_field = code_field
|
|
86
|
+
self._message_field = message_field
|
|
87
|
+
self._name_field = name_field
|
|
88
|
+
|
|
89
|
+
def supports(self, error: object) -> bool:
|
|
90
|
+
return is_class(error) and issubclass(error, self.base)
|
|
91
|
+
|
|
92
|
+
def describe(self, error: object) -> ErrorResponse:
|
|
93
|
+
error_class = cast('type', error)
|
|
94
|
+
return ErrorResponse(
|
|
95
|
+
status=self.status_for(error_class),
|
|
96
|
+
schema=self.schema_for(error_class),
|
|
97
|
+
name=error_class.__name__,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def status_for(self, error: type) -> int:
|
|
101
|
+
if self._status is not None:
|
|
102
|
+
return self._status(error)
|
|
103
|
+
if self._status_attr:
|
|
104
|
+
value = getattr(error, self._status_attr, None)
|
|
105
|
+
if isinstance(value, int):
|
|
106
|
+
return value
|
|
107
|
+
for klass in error.__mro__:
|
|
108
|
+
if klass in self._status_by_class:
|
|
109
|
+
return self._status_by_class[klass]
|
|
110
|
+
return self._default_status
|
|
111
|
+
|
|
112
|
+
def schema_for(self, error: type) -> JsonSchema:
|
|
113
|
+
properties: dict[str, Any] = {
|
|
114
|
+
self._code_field: self.code_schema(error),
|
|
115
|
+
self._name_field: {'type': 'string', 'const': error.__name__},
|
|
116
|
+
self._message_field: self.message_schema(error),
|
|
117
|
+
}
|
|
118
|
+
for name, annotation in self.payload_fields(error).items():
|
|
119
|
+
properties[name] = Ref(annotation)
|
|
120
|
+
schema: JsonSchema = {
|
|
121
|
+
'title': error.__name__,
|
|
122
|
+
'type': 'object',
|
|
123
|
+
'properties': properties,
|
|
124
|
+
'required': list(properties),
|
|
125
|
+
'additionalProperties': False,
|
|
126
|
+
}
|
|
127
|
+
doc = vars(error).get('__doc__')
|
|
128
|
+
if isinstance(doc, str) and doc.strip():
|
|
129
|
+
schema['description'] = doc.strip()
|
|
130
|
+
return schema
|
|
131
|
+
|
|
132
|
+
def code_schema(self, error: type) -> JsonSchema:
|
|
133
|
+
code = getattr(error, self._code_field, None)
|
|
134
|
+
if isinstance(code, int) and not isinstance(code, bool):
|
|
135
|
+
return {'type': 'integer', 'const': code}
|
|
136
|
+
return {'type': 'integer'}
|
|
137
|
+
|
|
138
|
+
def message_schema(self, error: type) -> JsonSchema:
|
|
139
|
+
message = getattr(error, self._message_field, None)
|
|
140
|
+
mode = getattr(error, MESSAGE_MODE_ATTR, None)
|
|
141
|
+
if not isinstance(message, str):
|
|
142
|
+
return {'type': 'string'}
|
|
143
|
+
if mode == 'const' or (mode != 'dynamic' and '{' not in message):
|
|
144
|
+
return {'type': 'string', 'const': message}
|
|
145
|
+
return {'type': 'string', 'examples': [message]}
|
|
146
|
+
|
|
147
|
+
def payload_fields(self, error: type) -> dict[str, Any]:
|
|
148
|
+
"""Payload field names and types: public annotated fields over the MRO.
|
|
149
|
+
|
|
150
|
+
Base classes come first; ``code``, ``message``, the name field,
|
|
151
|
+
``status_code``, ``_``-prefixed names and ``ClassVar`` are skipped.
|
|
152
|
+
"""
|
|
153
|
+
skip = {self._code_field, self._message_field, self._name_field, 'status_code'}
|
|
154
|
+
names: list[str] = []
|
|
155
|
+
for klass in reversed(error.__mro__):
|
|
156
|
+
for name in vars(klass).get('__annotations__', {}):
|
|
157
|
+
if not name.startswith('_') and name not in skip and name not in names:
|
|
158
|
+
names.append(name)
|
|
159
|
+
if not names:
|
|
160
|
+
return {}
|
|
161
|
+
try:
|
|
162
|
+
hints = get_type_hints(error, include_extras=True)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
raise ErrorAnnotationError(
|
|
165
|
+
f'Cannot evaluate annotations of {error.__module__}.{error.__qualname__}: '
|
|
166
|
+
f'{exc}. On Python {sys.version_info.major}.{sys.version_info.minor} '
|
|
167
|
+
'use typing.Optional/Union instead of "X | Y" in annotations.',
|
|
168
|
+
) from exc
|
|
169
|
+
return {
|
|
170
|
+
name: hints[name]
|
|
171
|
+
for name in names
|
|
172
|
+
if name in hints and get_origin(hints[name]) is not ClassVar
|
|
173
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from qstd_openapi.core.document import (
|
|
2
|
+
BuildResult,
|
|
3
|
+
Diagnostic,
|
|
4
|
+
OpenAPI,
|
|
5
|
+
OperationIds,
|
|
6
|
+
OperationIdStrategy,
|
|
7
|
+
validate_document,
|
|
8
|
+
)
|
|
9
|
+
from qstd_openapi.core.documents import Conflict, ConflictPolicy, Document
|
|
10
|
+
from qstd_openapi.core.error_responses import ErrorProvider, ErrorResponse
|
|
11
|
+
from qstd_openapi.core.filters import PathTag, ScopeFilter, TagRule
|
|
12
|
+
from qstd_openapi.core.schemas import (
|
|
13
|
+
BuiltinSchemas,
|
|
14
|
+
JsonSchema,
|
|
15
|
+
SchemaContext,
|
|
16
|
+
SchemaMode,
|
|
17
|
+
SchemaProvider,
|
|
18
|
+
SchemaRequest,
|
|
19
|
+
TypeOverride,
|
|
20
|
+
)
|
|
21
|
+
from qstd_openapi.core.sources import (
|
|
22
|
+
OperationSource,
|
|
23
|
+
RouteEntry,
|
|
24
|
+
Routes,
|
|
25
|
+
WebhookEntry,
|
|
26
|
+
WebhookSet,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = (
|
|
30
|
+
'BuildResult',
|
|
31
|
+
'BuiltinSchemas',
|
|
32
|
+
'Conflict',
|
|
33
|
+
'ConflictPolicy',
|
|
34
|
+
'Diagnostic',
|
|
35
|
+
'Document',
|
|
36
|
+
'ErrorProvider',
|
|
37
|
+
'ErrorResponse',
|
|
38
|
+
'JsonSchema',
|
|
39
|
+
'OpenAPI',
|
|
40
|
+
'OperationIdStrategy',
|
|
41
|
+
'OperationIds',
|
|
42
|
+
'OperationSource',
|
|
43
|
+
'PathTag',
|
|
44
|
+
'RouteEntry',
|
|
45
|
+
'Routes',
|
|
46
|
+
'SchemaContext',
|
|
47
|
+
'SchemaMode',
|
|
48
|
+
'SchemaProvider',
|
|
49
|
+
'SchemaRequest',
|
|
50
|
+
'ScopeFilter',
|
|
51
|
+
'TagRule',
|
|
52
|
+
'TypeOverride',
|
|
53
|
+
'WebhookEntry',
|
|
54
|
+
'WebhookSet',
|
|
55
|
+
'validate_document',
|
|
56
|
+
)
|