ninja-devx 0.0.1__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.
- ninja_devx/__init__.py +273 -0
- ninja_devx/_internal/__init__.py +1 -0
- ninja_devx/_internal/cache.py +16 -0
- ninja_devx/_internal/compat.py +19 -0
- ninja_devx/_internal/generics.py +130 -0
- ninja_devx/_internal/i18n.py +14 -0
- ninja_devx/_internal/streaming.py +154 -0
- ninja_devx/_internal/types.py +79 -0
- ninja_devx/_permission_eval.py +63 -0
- ninja_devx/_permission_eval_async.py +60 -0
- ninja_devx/apps.py +21 -0
- ninja_devx/codegen/__init__.py +14 -0
- ninja_devx/codegen/openapi.py +300 -0
- ninja_devx/codegen/python.py +833 -0
- ninja_devx/codegen/source.py +87 -0
- ninja_devx/codegen/typescript.py +367 -0
- ninja_devx/configuration/__init__.py +1 -0
- ninja_devx/configuration/checks.py +143 -0
- ninja_devx/configuration/runtime.py +33 -0
- ninja_devx/configuration/settings.py +222 -0
- ninja_devx/contrib/__init__.py +0 -0
- ninja_devx/contrib/apikeys/__init__.py +16 -0
- ninja_devx/contrib/apikeys/admin.py +51 -0
- ninja_devx/contrib/apikeys/api.py +92 -0
- ninja_devx/contrib/apikeys/apps.py +8 -0
- ninja_devx/contrib/apikeys/auth.py +213 -0
- ninja_devx/contrib/apikeys/management/__init__.py +0 -0
- ninja_devx/contrib/apikeys/management/commands/__init__.py +0 -0
- ninja_devx/contrib/apikeys/management/commands/devx_apikey.py +58 -0
- ninja_devx/contrib/apikeys/migrations/0001_initial.py +58 -0
- ninja_devx/contrib/apikeys/migrations/__init__.py +0 -0
- ninja_devx/contrib/apikeys/models.py +43 -0
- ninja_devx/contrib/audit/__init__.py +13 -0
- ninja_devx/contrib/audit/admin.py +36 -0
- ninja_devx/contrib/audit/api.py +122 -0
- ninja_devx/contrib/audit/apps.py +8 -0
- ninja_devx/contrib/audit/log.py +230 -0
- ninja_devx/contrib/audit/migrations/0001_initial.py +68 -0
- ninja_devx/contrib/audit/migrations/__init__.py +0 -0
- ninja_devx/contrib/audit/models.py +47 -0
- ninja_devx/contrib/audit/privacy.py +65 -0
- ninja_devx/contrib/dishka.py +135 -0
- ninja_devx/contrib/grants/__init__.py +6 -0
- ninja_devx/contrib/grants/admin.py +22 -0
- ninja_devx/contrib/grants/apps.py +8 -0
- ninja_devx/contrib/grants/backends.py +53 -0
- ninja_devx/contrib/grants/migrations/0001_initial.py +82 -0
- ninja_devx/contrib/grants/migrations/__init__.py +0 -0
- ninja_devx/contrib/grants/models.py +59 -0
- ninja_devx/contrib/otel.py +122 -0
- ninja_devx/contrib/svcs.py +54 -0
- ninja_devx/contrib/uploads/__init__.py +23 -0
- ninja_devx/contrib/uploads/api.py +317 -0
- ninja_devx/contrib/uploads/backends.py +201 -0
- ninja_devx/contrib/uploads/models.py +27 -0
- ninja_devx/contrib/webhooks/__init__.py +19 -0
- ninja_devx/contrib/webhooks/admin.py +146 -0
- ninja_devx/contrib/webhooks/api.py +198 -0
- ninja_devx/contrib/webhooks/apps.py +8 -0
- ninja_devx/contrib/webhooks/maintenance.py +57 -0
- ninja_devx/contrib/webhooks/management/__init__.py +0 -0
- ninja_devx/contrib/webhooks/management/commands/__init__.py +0 -0
- ninja_devx/contrib/webhooks/management/commands/devx_webhooks.py +115 -0
- ninja_devx/contrib/webhooks/migrations/0001_initial.py +126 -0
- ninja_devx/contrib/webhooks/migrations/0002_outboxevent_audience_webhookdelivery_lease_token_and_more.py +27 -0
- ninja_devx/contrib/webhooks/migrations/__init__.py +0 -0
- ninja_devx/contrib/webhooks/models.py +119 -0
- ninja_devx/contrib/webhooks/network.py +173 -0
- ninja_devx/contrib/webhooks/outbox.py +375 -0
- ninja_devx/contrib/webhooks/secrets.py +95 -0
- ninja_devx/contrib/webhooks/signing.py +104 -0
- ninja_devx/contrib/webhooks/tasks.py +35 -0
- ninja_devx/crud/__init__.py +84 -0
- ninja_devx/crud/annotations.py +274 -0
- ninja_devx/crud/async_controllers.py +21 -0
- ninja_devx/crud/auto.py +140 -0
- ninja_devx/crud/bulk.py +198 -0
- ninja_devx/crud/controllers.py +822 -0
- ninja_devx/crud/fields.py +94 -0
- ninja_devx/crud/filters.py +119 -0
- ninja_devx/crud/nested.py +130 -0
- ninja_devx/crud/optimization.py +187 -0
- ninja_devx/crud/pagination.py +252 -0
- ninja_devx/crud/persistence.py +5 -0
- ninja_devx/crud/scoping.py +58 -0
- ninja_devx/crud/shaping.py +136 -0
- ninja_devx/crud/sharing.py +164 -0
- ninja_devx/crud/soft_delete.py +182 -0
- ninja_devx/crud/transfer.py +350 -0
- ninja_devx/crud/writes.py +51 -0
- ninja_devx/dependencies/__init__.py +1 -0
- ninja_devx/dependencies/container.py +302 -0
- ninja_devx/dependencies/contracts.py +67 -0
- ninja_devx/dependencies/engine.py +287 -0
- ninja_devx/dependencies/injection.py +144 -0
- ninja_devx/dependencies/instances.py +206 -0
- ninja_devx/dependencies/introspection.py +51 -0
- ninja_devx/dependencies/scope.py +61 -0
- ninja_devx/dependencies/state.py +82 -0
- ninja_devx/exceptions.py +42 -0
- ninja_devx/http/__init__.py +1 -0
- ninja_devx/http/conditional.py +207 -0
- ninja_devx/http/errors.py +180 -0
- ninja_devx/http/health.py +221 -0
- ninja_devx/http/middleware.py +284 -0
- ninja_devx/http/throttling.py +226 -0
- ninja_devx/idempotency/__init__.py +5 -0
- ninja_devx/idempotency/models.py +29 -0
- ninja_devx/idempotency/policy.py +172 -0
- ninja_devx/idempotency/store.py +114 -0
- ninja_devx/layers/__init__.py +58 -0
- ninja_devx/layers/context.py +39 -0
- ninja_devx/layers/dual.py +98 -0
- ninja_devx/layers/errors.py +113 -0
- ninja_devx/layers/persistence.py +82 -0
- ninja_devx/layers/policies.py +41 -0
- ninja_devx/layers/repository.py +111 -0
- ninja_devx/layers/selectors.py +19 -0
- ninja_devx/layers/services.py +54 -0
- ninja_devx/layers/tasks.py +93 -0
- ninja_devx/layers/testing.py +75 -0
- ninja_devx/management/__init__.py +0 -0
- ninja_devx/management/commands/__init__.py +0 -0
- ninja_devx/management/commands/devx_openapi.py +71 -0
- ninja_devx/management/commands/devx_scaffold.py +124 -0
- ninja_devx/management/commands/devx_startapp.py +33 -0
- ninja_devx/management/commands/devx_uploads.py +31 -0
- ninja_devx/migrations/0001_initial.py +31 -0
- ninja_devx/migrations/0002_uploadrecord.py +37 -0
- ninja_devx/migrations/__init__.py +0 -0
- ninja_devx/models.py +6 -0
- ninja_devx/py.typed +0 -0
- ninja_devx/pytest_plugin.py +3 -0
- ninja_devx/routing/__init__.py +1 -0
- ninja_devx/routing/bindings.py +58 -0
- ninja_devx/routing/compiler.py +510 -0
- ninja_devx/routing/controller.py +436 -0
- ninja_devx/routing/hooks.py +136 -0
- ninja_devx/routing/invocation.py +368 -0
- ninja_devx/routing/mounting.py +82 -0
- ninja_devx/routing/operations.py +203 -0
- ninja_devx/routing/plugins.py +46 -0
- ninja_devx/routing/use_cases.py +128 -0
- ninja_devx/security/__init__.py +1 -0
- ninja_devx/security/auth.py +224 -0
- ninja_devx/security/object_permissions.py +456 -0
- ninja_devx/security/permission_leaf.py +50 -0
- ninja_devx/security/permissions.py +486 -0
- ninja_devx/security/tenancy.py +130 -0
- ninja_devx/serialization/__init__.py +1 -0
- ninja_devx/serialization/pydantic.py +24 -0
- ninja_devx/serialization/renderers.py +64 -0
- ninja_devx/serialization/schemas.py +167 -0
- ninja_devx/serialization/visibility.py +278 -0
- ninja_devx/templates/app_template/__init__.py-tpl +0 -0
- ninja_devx/templates/app_template/api.py-tpl +19 -0
- ninja_devx/templates/app_template/apps.py-tpl +6 -0
- ninja_devx/templates/app_template/migrations/__init__.py-tpl +0 -0
- ninja_devx/templates/app_template/models.py-tpl +4 -0
- ninja_devx/templates/app_template/schemas.py-tpl +6 -0
- ninja_devx/templates/app_template/services.py-tpl +6 -0
- ninja_devx/templates/app_template/tests/__init__.py-tpl +0 -0
- ninja_devx/templates/app_template/tests/test_api.py-tpl +13 -0
- ninja_devx/testing/__init__.py +1 -0
- ninja_devx/testing/clients.py +200 -0
- ninja_devx/testing/contracts.py +58 -0
- ninja_devx/testing/plugin.py +169 -0
- ninja_devx/tooling/__init__.py +1 -0
- ninja_devx/tooling/drift.py +252 -0
- ninja_devx/tooling/scaffold.py +419 -0
- ninja_devx/tooling/unasync.py +183 -0
- ninja_devx/unasync.py +6 -0
- ninja_devx-0.0.1.dist-info/METADATA +171 -0
- ninja_devx-0.0.1.dist-info/RECORD +178 -0
- ninja_devx-0.0.1.dist-info/WHEEL +4 -0
- ninja_devx-0.0.1.dist-info/entry_points.txt +2 -0
- ninja_devx-0.0.1.dist-info/licenses/LICENSE +202 -0
- ninja_devx-0.0.1.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Generated by `python -m ninja_devx.tooling.unasync` from:
|
|
2
|
+
# src/ninja_devx/_permission_eval_async.py
|
|
3
|
+
# Do not edit.
|
|
4
|
+
"""Evaluate a permission tree, deferring object-dependent decisions until lookup.
|
|
5
|
+
|
|
6
|
+
``_permission_eval.py`` is generated from this file with ``python -m ninja_devx.tooling.unasync``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from django.http import HttpRequest
|
|
14
|
+
|
|
15
|
+
from .security import permission_leaf as _permission_leaf
|
|
16
|
+
from .security.permission_leaf import Target
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .security.permissions import AnyPermission
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def denied(
|
|
23
|
+
permission: AnyPermission, request: HttpRequest, target: Target
|
|
24
|
+
) -> AnyPermission | None:
|
|
25
|
+
allowed, refused = _evaluate(permission, request, target)
|
|
26
|
+
return refused if allowed is False else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _evaluate(
|
|
30
|
+
permission: AnyPermission, request: HttpRequest, target: Target
|
|
31
|
+
) -> tuple[bool | None, AnyPermission | None]:
|
|
32
|
+
"""None is unknown, not allowed: object checks cannot run before an object exists.
|
|
33
|
+
|
|
34
|
+
Preserve unknown through negation and nested Boolean expressions. At object time
|
|
35
|
+
each leaf must satisfy both its request and object checks, so OR cannot switch
|
|
36
|
+
between two independently failing branches.
|
|
37
|
+
"""
|
|
38
|
+
combinator = permission.combinator
|
|
39
|
+
if combinator in {"all", "any"}:
|
|
40
|
+
pending = False
|
|
41
|
+
refused = permission
|
|
42
|
+
for child in permission.operands:
|
|
43
|
+
allowed, child_refused = _evaluate(child, request, target)
|
|
44
|
+
if combinator == "all" and allowed is False:
|
|
45
|
+
return False, child_refused
|
|
46
|
+
if combinator == "any" and allowed is True:
|
|
47
|
+
return True, None
|
|
48
|
+
pending |= allowed is None
|
|
49
|
+
if child_refused is not None:
|
|
50
|
+
refused = child_refused
|
|
51
|
+
if pending:
|
|
52
|
+
return None, None
|
|
53
|
+
return (True, None) if combinator == "all" else (False, refused)
|
|
54
|
+
if combinator == "not":
|
|
55
|
+
allowed, _ = _evaluate(permission.operands[0], request, target)
|
|
56
|
+
if allowed is None:
|
|
57
|
+
return None, None
|
|
58
|
+
return (False, permission) if allowed else (True, None)
|
|
59
|
+
if not _permission_leaf.leaf_allows(permission, request, target):
|
|
60
|
+
return False, permission
|
|
61
|
+
if target is None and _permission_leaf.checks_objects(permission):
|
|
62
|
+
return None, None
|
|
63
|
+
return True, None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Evaluate a permission tree, deferring object-dependent decisions until lookup.
|
|
2
|
+
|
|
3
|
+
``_permission_eval.py`` is generated from this file with ``python -m ninja_devx.tooling.unasync``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from django.http import HttpRequest
|
|
11
|
+
|
|
12
|
+
from .security import permission_leaf as _permission_leaf
|
|
13
|
+
from .security.permission_leaf import Target
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .security.permissions import AnyPermission
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def adenied(
|
|
20
|
+
permission: AnyPermission, request: HttpRequest, target: Target
|
|
21
|
+
) -> AnyPermission | None:
|
|
22
|
+
allowed, refused = await _evaluate(permission, request, target)
|
|
23
|
+
return refused if allowed is False else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _evaluate(
|
|
27
|
+
permission: AnyPermission, request: HttpRequest, target: Target
|
|
28
|
+
) -> tuple[bool | None, AnyPermission | None]:
|
|
29
|
+
"""None is unknown, not allowed: object checks cannot run before an object exists.
|
|
30
|
+
|
|
31
|
+
Preserve unknown through negation and nested Boolean expressions. At object time
|
|
32
|
+
each leaf must satisfy both its request and object checks, so OR cannot switch
|
|
33
|
+
between two independently failing branches.
|
|
34
|
+
"""
|
|
35
|
+
combinator = permission.combinator
|
|
36
|
+
if combinator in {"all", "any"}:
|
|
37
|
+
pending = False
|
|
38
|
+
refused = permission
|
|
39
|
+
for child in permission.operands:
|
|
40
|
+
allowed, child_refused = await _evaluate(child, request, target)
|
|
41
|
+
if combinator == "all" and allowed is False:
|
|
42
|
+
return False, child_refused
|
|
43
|
+
if combinator == "any" and allowed is True:
|
|
44
|
+
return True, None
|
|
45
|
+
pending |= allowed is None
|
|
46
|
+
if child_refused is not None:
|
|
47
|
+
refused = child_refused
|
|
48
|
+
if pending:
|
|
49
|
+
return None, None
|
|
50
|
+
return (True, None) if combinator == "all" else (False, refused)
|
|
51
|
+
if combinator == "not":
|
|
52
|
+
allowed, _ = await _evaluate(permission.operands[0], request, target)
|
|
53
|
+
if allowed is None:
|
|
54
|
+
return None, None
|
|
55
|
+
return (False, permission) if allowed else (True, None)
|
|
56
|
+
if not await _permission_leaf.aleaf_allows(permission, request, target):
|
|
57
|
+
return False, permission
|
|
58
|
+
if target is None and _permission_leaf.checks_objects(permission):
|
|
59
|
+
return None, None
|
|
60
|
+
return True, None
|
ninja_devx/apps.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from types import ModuleType
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
from .configuration.runtime import RuntimeState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NinjaDevXConfig(AppConfig):
|
|
9
|
+
"""Add ``"ninja_devx"`` to ``INSTALLED_APPS`` for the management commands."""
|
|
10
|
+
|
|
11
|
+
name = "ninja_devx"
|
|
12
|
+
verbose_name = "Ninja DevX"
|
|
13
|
+
|
|
14
|
+
def __init__(self, app_name: str, app_module: ModuleType) -> None:
|
|
15
|
+
super().__init__(app_name, app_module)
|
|
16
|
+
self.runtime = RuntimeState()
|
|
17
|
+
|
|
18
|
+
def ready(self) -> None:
|
|
19
|
+
from .configuration.checks import check_controllers
|
|
20
|
+
|
|
21
|
+
del check_controllers # imported to register the system checks
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Typed API clients generated from a Django Ninja OpenAPI schema."""
|
|
2
|
+
|
|
3
|
+
from .openapi import Operation, Parameter, load_api, read_operations
|
|
4
|
+
from .python import generate_python
|
|
5
|
+
from .typescript import generate_typescript
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Operation",
|
|
9
|
+
"Parameter",
|
|
10
|
+
"generate_python",
|
|
11
|
+
"generate_typescript",
|
|
12
|
+
"load_api",
|
|
13
|
+
"read_operations",
|
|
14
|
+
]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""A small, typed view of the OpenAPI documents Django Ninja produces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal, TypeAlias, cast
|
|
9
|
+
|
|
10
|
+
from django.utils.module_loading import import_string
|
|
11
|
+
from ninja import NinjaAPI
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Operation",
|
|
15
|
+
"Parameter",
|
|
16
|
+
"Schema",
|
|
17
|
+
"as_list",
|
|
18
|
+
"as_mapping",
|
|
19
|
+
"as_strings",
|
|
20
|
+
"load_api",
|
|
21
|
+
"read_operations",
|
|
22
|
+
"ref_name",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
Schema: TypeAlias = Mapping[str, object]
|
|
26
|
+
Location = Literal["path", "query", "header", "cookie"]
|
|
27
|
+
_METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Parameter:
|
|
32
|
+
name: str
|
|
33
|
+
location: Location
|
|
34
|
+
required: bool
|
|
35
|
+
schema: Schema
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ResponseVariant:
|
|
40
|
+
status: str
|
|
41
|
+
media_type: str | None
|
|
42
|
+
schema: Schema
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Operation:
|
|
47
|
+
operation_id: str
|
|
48
|
+
method: str
|
|
49
|
+
path: str
|
|
50
|
+
summary: str
|
|
51
|
+
parameters: tuple[Parameter, ...]
|
|
52
|
+
body: Schema | None
|
|
53
|
+
body_required: bool
|
|
54
|
+
response: Schema | None
|
|
55
|
+
"""Union of all documented 2xx JSON, text and binary representations."""
|
|
56
|
+
has_body: bool
|
|
57
|
+
"""Whether any documented success response has a body."""
|
|
58
|
+
response_variants: tuple[ResponseVariant, ...] = ()
|
|
59
|
+
body_media_type: str = "application/json"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_api(path: str) -> NinjaAPI:
|
|
63
|
+
"""Import a ``NinjaAPI`` from a dotted path, e.g. ``config.urls.api``."""
|
|
64
|
+
api: object = import_string(path)
|
|
65
|
+
if not isinstance(api, NinjaAPI):
|
|
66
|
+
raise TypeError(f"{path} is not a NinjaAPI instance")
|
|
67
|
+
return api
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def ref_name(reference: str) -> str:
|
|
71
|
+
return reference.rsplit("/", 1)[-1]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def named_multipart_bodies(document: Schema) -> Schema:
|
|
75
|
+
"""Give inline multipart objects stable model names without mutating the input."""
|
|
76
|
+
components = dict(_mapping(document.get("components")))
|
|
77
|
+
schemas = dict(_mapping(components.get("schemas")))
|
|
78
|
+
paths: dict[str, object] = {}
|
|
79
|
+
counter = 0
|
|
80
|
+
for path, raw_path in _mapping(document.get("paths")).items():
|
|
81
|
+
path_item = dict(_mapping(raw_path))
|
|
82
|
+
for method in _METHODS:
|
|
83
|
+
if method not in path_item:
|
|
84
|
+
continue
|
|
85
|
+
operation = dict(_mapping(path_item[method]))
|
|
86
|
+
body = dict(_resolve(document, operation.get("requestBody"), "requestBodies"))
|
|
87
|
+
content = dict(_mapping(body.get("content")))
|
|
88
|
+
part = dict(_mapping(content.get("multipart/form-data")))
|
|
89
|
+
schema = _mapping(part.get("schema"))
|
|
90
|
+
if schema.get("type") != "object" or not schema.get("properties"):
|
|
91
|
+
continue
|
|
92
|
+
while f"MultipartBody{counter}" in schemas:
|
|
93
|
+
counter += 1
|
|
94
|
+
name = f"MultipartBody{counter}"
|
|
95
|
+
counter += 1
|
|
96
|
+
schemas[name] = schema
|
|
97
|
+
part["schema"] = {"$ref": f"#/components/schemas/{name}"}
|
|
98
|
+
content["multipart/form-data"] = part
|
|
99
|
+
body["content"] = content
|
|
100
|
+
operation["requestBody"] = body
|
|
101
|
+
path_item[method] = operation
|
|
102
|
+
paths[path] = path_item
|
|
103
|
+
components["schemas"] = schemas
|
|
104
|
+
return {**document, "paths": paths, "components": components}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def read_operations(document: Schema) -> list[Operation]:
|
|
108
|
+
operations: list[Operation] = []
|
|
109
|
+
for path, item in _mapping(document.get("paths")).items():
|
|
110
|
+
path_item = _mapping(item)
|
|
111
|
+
for method, raw in path_item.items():
|
|
112
|
+
if method not in _METHODS:
|
|
113
|
+
continue
|
|
114
|
+
operation = _mapping(raw)
|
|
115
|
+
operation_id = str(operation.get("operationId") or _fallback_id(method, path))
|
|
116
|
+
parameters: dict[tuple[Location, str], Parameter] = {}
|
|
117
|
+
for raw_parameter in [
|
|
118
|
+
*_sequence(path_item.get("parameters")),
|
|
119
|
+
*_sequence(operation.get("parameters")),
|
|
120
|
+
]:
|
|
121
|
+
parameter = _resolve(document, raw_parameter, "parameters")
|
|
122
|
+
location = _location(parameter.get("in"))
|
|
123
|
+
name = str(parameter["name"])
|
|
124
|
+
if location == "cookie":
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"{operation_id}: cookie parameter {name!r} is unsupported; "
|
|
127
|
+
"configure session credentials on the HTTP client"
|
|
128
|
+
)
|
|
129
|
+
expected_style = "form" if location == "query" else "simple"
|
|
130
|
+
if parameter.get("style", expected_style) != expected_style:
|
|
131
|
+
raise ValueError(f"{operation_id}: unsupported parameter style for {name!r}")
|
|
132
|
+
if location == "query" and parameter.get("explode", True) is not True:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"{operation_id}: query explode=False is unsupported for {name!r}"
|
|
135
|
+
)
|
|
136
|
+
if parameter.get("allowReserved"):
|
|
137
|
+
raise ValueError(f"{operation_id}: allowReserved is unsupported for {name!r}")
|
|
138
|
+
schema = _mapping(parameter.get("schema"))
|
|
139
|
+
if parameter.get("content") or schema.get("type") == "object":
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"{operation_id}: structured parameter {name!r} is unsupported"
|
|
142
|
+
)
|
|
143
|
+
parameters[location, name] = Parameter(
|
|
144
|
+
name=name,
|
|
145
|
+
location=location,
|
|
146
|
+
required=bool(parameter.get("required", False)),
|
|
147
|
+
schema=schema,
|
|
148
|
+
)
|
|
149
|
+
body = _resolve(document, operation.get("requestBody"), "requestBodies")
|
|
150
|
+
body_content = _mapping(body.get("content"))
|
|
151
|
+
body_schema = _json_schema(body_content) if body else None
|
|
152
|
+
body_media_type = "application/json"
|
|
153
|
+
if body and body_schema is None and "multipart/form-data" in body_content:
|
|
154
|
+
part = _mapping(body_content["multipart/form-data"])
|
|
155
|
+
if part.get("encoding"):
|
|
156
|
+
raise ValueError(f"{operation_id}: custom multipart encoding is unsupported")
|
|
157
|
+
body_schema = _mapping(part.get("schema"))
|
|
158
|
+
resolved = _resolve(document, body_schema, "schemas")
|
|
159
|
+
if resolved.get("type") != "object":
|
|
160
|
+
raise ValueError(f"{operation_id}: multipart body must be an object")
|
|
161
|
+
for field_name, value in _mapping(resolved.get("properties")).items():
|
|
162
|
+
field = _mapping(value)
|
|
163
|
+
if field.get("type") not in {"string", "integer", "number", "boolean"}:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"{operation_id}: unsupported multipart field {field_name!r}"
|
|
166
|
+
)
|
|
167
|
+
required = set(as_strings(resolved.get("required")))
|
|
168
|
+
if not any(
|
|
169
|
+
name in required and _mapping(value).get("format") == "binary"
|
|
170
|
+
for name, value in _mapping(resolved.get("properties")).items()
|
|
171
|
+
):
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"{operation_id}: unsupported multipart body without a required file"
|
|
174
|
+
)
|
|
175
|
+
body_media_type = "multipart/form-data"
|
|
176
|
+
if body and body_schema is None:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"{operation_id}: unsupported request media types {sorted(body_content)}; "
|
|
179
|
+
"JSON or simple multipart/form-data is required"
|
|
180
|
+
)
|
|
181
|
+
responses = {
|
|
182
|
+
code: _resolve(document, value, "responses")
|
|
183
|
+
for code, value in _mapping(operation.get("responses")).items()
|
|
184
|
+
}
|
|
185
|
+
response, has_body, variants = _success_schema(responses, operation_id)
|
|
186
|
+
operations.append(
|
|
187
|
+
Operation(
|
|
188
|
+
operation_id=operation_id,
|
|
189
|
+
method=method.upper(),
|
|
190
|
+
path=path,
|
|
191
|
+
summary=str(operation.get("summary", "")),
|
|
192
|
+
parameters=tuple(parameters.values()),
|
|
193
|
+
body=body_schema,
|
|
194
|
+
body_required=bool(body.get("required", False)),
|
|
195
|
+
response=response,
|
|
196
|
+
has_body=has_body,
|
|
197
|
+
response_variants=variants,
|
|
198
|
+
body_media_type=body_media_type,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
return operations
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _resolve(document: Schema, value: object, section: str) -> Schema:
|
|
205
|
+
mapping = _mapping(value)
|
|
206
|
+
seen: set[str] = set()
|
|
207
|
+
while "$ref" in mapping:
|
|
208
|
+
reference = str(mapping["$ref"])
|
|
209
|
+
prefix = f"#/components/{section}/"
|
|
210
|
+
if not reference.startswith(prefix) or reference in seen:
|
|
211
|
+
raise ValueError(f"Unsupported or circular {section} reference: {reference!r}")
|
|
212
|
+
seen.add(reference)
|
|
213
|
+
name = reference.removeprefix(prefix).replace("~1", "/").replace("~0", "~")
|
|
214
|
+
entries = _mapping(_mapping(document.get("components")).get(section))
|
|
215
|
+
if name not in entries:
|
|
216
|
+
raise ValueError(f"Unknown {section} reference: {reference!r}")
|
|
217
|
+
mapping = _mapping(entries[name])
|
|
218
|
+
return mapping
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _success_schema(
|
|
222
|
+
responses: Schema, operation_id: str
|
|
223
|
+
) -> tuple[Schema | None, bool, tuple[ResponseVariant, ...]]:
|
|
224
|
+
schemas: list[Schema] = []
|
|
225
|
+
variants: list[ResponseVariant] = []
|
|
226
|
+
has_body = False
|
|
227
|
+
for code, raw in sorted(responses.items()):
|
|
228
|
+
if not code.startswith("2"):
|
|
229
|
+
continue
|
|
230
|
+
content = _mapping(_mapping(raw).get("content"))
|
|
231
|
+
if code == "204":
|
|
232
|
+
empty = {"type": "null", "x-empty-response": True}
|
|
233
|
+
schemas.append(empty)
|
|
234
|
+
variants.append(ResponseVariant(code, None, empty))
|
|
235
|
+
continue
|
|
236
|
+
has_body = True
|
|
237
|
+
if not content:
|
|
238
|
+
# Ninja uses a description-only 200 for an unannotated response. Its
|
|
239
|
+
# body is unknown, not necessarily empty.
|
|
240
|
+
schemas.append({})
|
|
241
|
+
variants.append(ResponseVariant(code, None, {}))
|
|
242
|
+
continue
|
|
243
|
+
for media_type, representation in content.items():
|
|
244
|
+
media_type = media_type.split(";", 1)[0].lower()
|
|
245
|
+
if media_type == "text/event-stream":
|
|
246
|
+
raise ValueError(f"{operation_id}: streaming responses are unsupported")
|
|
247
|
+
if _is_json(media_type):
|
|
248
|
+
schema = _mapping(_mapping(representation).get("schema"))
|
|
249
|
+
elif media_type.startswith("text/"):
|
|
250
|
+
schema = {"type": "string"}
|
|
251
|
+
else:
|
|
252
|
+
schema = {"type": "string", "x-binary-response": True}
|
|
253
|
+
variants.append(ResponseVariant(code, media_type, schema))
|
|
254
|
+
if schema not in schemas:
|
|
255
|
+
schemas.append(schema)
|
|
256
|
+
if not has_body:
|
|
257
|
+
return None, False, tuple(variants)
|
|
258
|
+
return (schemas[0] if len(schemas) == 1 else {"anyOf": schemas}), True, tuple(variants)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _is_json(media_type: str) -> bool:
|
|
262
|
+
media_type = media_type.split(";", 1)[0].lower()
|
|
263
|
+
return media_type == "application/json" or media_type.endswith("+json")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _json_schema(content: Schema) -> Schema | None:
|
|
267
|
+
for media_type, value in content.items():
|
|
268
|
+
if _is_json(media_type):
|
|
269
|
+
return _mapping(_mapping(value).get("schema"))
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _location(value: object) -> Location:
|
|
274
|
+
if value in ("path", "query", "header", "cookie"):
|
|
275
|
+
return value
|
|
276
|
+
raise ValueError(f"Unsupported parameter location {value!r}")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _fallback_id(method: str, path: str) -> str:
|
|
280
|
+
return f"{method}_{re.sub(r'[^0-9a-zA-Z]+', '_', path).strip('_')}"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def as_mapping(value: object) -> Schema:
|
|
284
|
+
"""``value`` as a string-keyed mapping, or an empty one."""
|
|
285
|
+
if not isinstance(value, Mapping):
|
|
286
|
+
return {}
|
|
287
|
+
items = cast("Mapping[object, object]", value).items()
|
|
288
|
+
return {str(key): item for key, item in items}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def as_list(value: object) -> list[object]:
|
|
292
|
+
return list(cast("Sequence[object]", value)) if isinstance(value, list | tuple) else []
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def as_strings(value: object) -> list[str]:
|
|
296
|
+
return [str(item) for item in as_list(value)]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
_mapping = as_mapping
|
|
300
|
+
_sequence = as_list
|