seampact-codegen 1.0.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.
- seampact/codegen/__init__.py +2 -0
- seampact/codegen/boot_check_hooks.py +38 -0
- seampact/codegen/contract_module.py +146 -0
- seampact/codegen/credentials_classes.py +99 -0
- seampact/codegen/handler_forms.py +132 -0
- seampact/codegen/input_schemas.py +130 -0
- seampact/codegen/kept_forms.py +522 -0
- seampact/codegen/matrix_row.py +23 -0
- seampact/codegen/names.py +146 -0
- seampact/codegen/native_docs_file.py +169 -0
- seampact/codegen/operation_types.py +425 -0
- seampact/codegen/placement_lines.py +79 -0
- seampact/codegen/printer.py +196 -0
- seampact/codegen/py.typed +0 -0
- seampact/codegen/pydantic_forms.py +53 -0
- seampact/codegen/pydantic_layout.py +178 -0
- seampact/codegen/pydantic_models.py +405 -0
- seampact/codegen/redacted_members.py +47 -0
- seampact/codegen/refusals.py +84 -0
- seampact/codegen/request_deadline.py +68 -0
- seampact/codegen/response_header_forms.py +73 -0
- seampact/codegen/restricted_sort_keys.py +18 -0
- seampact/codegen/route_helpers.py +109 -0
- seampact/codegen/routes.py +139 -0
- seampact/codegen/runtime/__init__.py +7 -0
- seampact/codegen/runtime/access_kinds.py +24 -0
- seampact/codegen/runtime/access_steps.py +520 -0
- seampact/codegen/runtime/body_reader.py +64 -0
- seampact/codegen/runtime/cache_headers.py +89 -0
- seampact/codegen/runtime/contract.py +232 -0
- seampact/codegen/runtime/cors_preflight.py +20 -0
- seampact/codegen/runtime/deadline.py +72 -0
- seampact/codegen/runtime/digests.py +98 -0
- seampact/codegen/runtime/failures.py +33 -0
- seampact/codegen/runtime/field_rights.py +193 -0
- seampact/codegen/runtime/framework_routes.py +36 -0
- seampact/codegen/runtime/framework_versions.py +91 -0
- seampact/codegen/runtime/handler_call.py +31 -0
- seampact/codegen/runtime/handler_reply.py +148 -0
- seampact/codegen/runtime/hinted_values.py +48 -0
- seampact/codegen/runtime/hook_check.py +77 -0
- seampact/codegen/runtime/idempotency.py +279 -0
- seampact/codegen/runtime/input_decoding.py +226 -0
- seampact/codegen/runtime/ir_view.py +47 -0
- seampact/codegen/runtime/native_docs.py +130 -0
- seampact/codegen/runtime/page_facts.py +33 -0
- seampact/codegen/runtime/page_reply.py +396 -0
- seampact/codegen/runtime/pattern.py +69 -0
- seampact/codegen/runtime/principal_members.py +41 -0
- seampact/codegen/runtime/public_origin.py +102 -0
- seampact/codegen/runtime/pydantic/__init__.py +165 -0
- seampact/codegen/runtime/pydantic/checked_input.py +52 -0
- seampact/codegen/runtime/pydantic/contract_input.py +183 -0
- seampact/codegen/runtime/pydantic/issues.py +142 -0
- seampact/codegen/runtime/raw_target.py +103 -0
- seampact/codegen/runtime/replies.py +144 -0
- seampact/codegen/runtime/reply_verdict.py +215 -0
- seampact/codegen/runtime/requirement_params.py +170 -0
- seampact/codegen/runtime/response_header_entries.py +226 -0
- seampact/codegen/runtime/response_headers.py +114 -0
- seampact/codegen/runtime/route_match.py +88 -0
- seampact/codegen/runtime/run_settings.py +35 -0
- seampact/codegen/runtime/signature_header.py +92 -0
- seampact/codegen/runtime/sync_driver.py +35 -0
- seampact/codegen/runtime/templates.py +132 -0
- seampact/codegen/runtime/uuid_text.py +7 -0
- seampact/codegen/runtime/value_rules.py +218 -0
- seampact/codegen/runtime/version.py +59 -0
- seampact/codegen/runtime/version_token.py +108 -0
- seampact/codegen/runtime/wire_codec.py +229 -0
- seampact/codegen/runtime/wire_values.py +260 -0
- seampact/codegen/source_lines.py +35 -0
- seampact_codegen-1.0.0.dist-info/METADATA +18 -0
- seampact_codegen-1.0.0.dist-info/RECORD +76 -0
- seampact_codegen-1.0.0.dist-info/WHEEL +4 -0
- seampact_codegen-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""The api's contract as the boundary reads it: the IR view of the kept operations and the declarations they read,
|
|
2
|
+
which the generated code holds as JSON values, the Python names of each schema's members, and the routes, built once
|
|
3
|
+
per process."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from seampact.codegen.runtime.ir_view import IrJson, IrMap, ir_int, ir_map, ir_maps, ir_string
|
|
10
|
+
from seampact.codegen.runtime.response_header_entries import HeaderEntry, HeaderProblem, response_header_entry
|
|
11
|
+
from seampact.codegen.runtime.route_match import ContractRoute, RouteMatch, most_specific_first
|
|
12
|
+
|
|
13
|
+
NOT_FOUND_ROLE: Final = "notFound"
|
|
14
|
+
# The role a framework's HTTP error takes on a contract route, by its status; another status has no role.
|
|
15
|
+
ROLE_OF_STATUS: Final[Mapping[int, str]] = {
|
|
16
|
+
401: "unauthenticated",
|
|
17
|
+
403: "forbidden",
|
|
18
|
+
404: NOT_FOUND_ROLE,
|
|
19
|
+
413: "payloadTooLarge",
|
|
20
|
+
429: "rateLimited",
|
|
21
|
+
503: "serviceUnavailable",
|
|
22
|
+
}
|
|
23
|
+
UNDECLARED_STATUS_ROLE: Final = "undeclaredStatus"
|
|
24
|
+
INTERNAL_STATUS: Final = 500
|
|
25
|
+
|
|
26
|
+
PATH_PART: Final = "path"
|
|
27
|
+
QUERY_PART: Final = "query"
|
|
28
|
+
HEADER_PART: Final = "header"
|
|
29
|
+
PROOFS_PART: Final = "proofs"
|
|
30
|
+
"""The parts of an operation's input whose wire names `operation_members` maps: its path, query and header
|
|
31
|
+
parameters (the credential and CSRF headers stay the boundary's), and the rule proofs read in its body. The header
|
|
32
|
+
part is spelled as the IR spells it."""
|
|
33
|
+
_KNOWN_PARTS: Final = (PATH_PART, QUERY_PART, HEADER_PART, PROOFS_PART)
|
|
34
|
+
|
|
35
|
+
_IGNORE_POLICY: Final = "ignore"
|
|
36
|
+
_REFUSE_POLICY: Final = "refuse"
|
|
37
|
+
_HEAD: Final = "HEAD"
|
|
38
|
+
_GET: Final = "GET"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ApiContract:
|
|
43
|
+
"""`ir_view` is the IR's JSON subset the generator wrote (`api`, `formats`, `errors`, `schemas`, `enums`,
|
|
44
|
+
`operations`, and the access declarations `identities`, `schemes`, `scopes`, `rights`, `rules`, `buckets` and
|
|
45
|
+
`pagings`); `members` maps each schema id to its members' wire names and Python attribute names;
|
|
46
|
+
`operation_members` maps each operation id, then each part of its input, to the same; `params_models` maps the
|
|
47
|
+
params schema id of each right and rule to its generated class; `mount` is the entry's prefix of every contract
|
|
48
|
+
path, empty for none."""
|
|
49
|
+
|
|
50
|
+
api: str
|
|
51
|
+
generated_by: str
|
|
52
|
+
mount: str
|
|
53
|
+
ir_view: Mapping[str, IrJson]
|
|
54
|
+
members: Mapping[str, Mapping[str, str]]
|
|
55
|
+
operation_members: Mapping[str, Mapping[str, Mapping[str, str]]]
|
|
56
|
+
params_models: Mapping[str, type] = field(default_factory=dict[str, type])
|
|
57
|
+
_operations: dict[str, IrMap] = field(init=False, repr=False)
|
|
58
|
+
_routes: tuple[ContractRoute, ...] = field(init=False, repr=False)
|
|
59
|
+
_base_paths: tuple[str, ...] = field(init=False, repr=False)
|
|
60
|
+
_header_entries: dict[tuple[str, int], tuple[HeaderEntry | HeaderProblem, ...]] = field(init=False, repr=False)
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
operations: dict[str, IrMap] = {}
|
|
64
|
+
routes: list[ContractRoute] = []
|
|
65
|
+
base_paths: list[str] = []
|
|
66
|
+
surfaces = ir_map(ir_map(self.ir_view, "api"), "surfaces")
|
|
67
|
+
|
|
68
|
+
for operation in ir_maps(self.ir_view, "operations"):
|
|
69
|
+
operation_id = ir_string(operation, "id") or ""
|
|
70
|
+
base_path = self.mount + (
|
|
71
|
+
ir_string(ir_map(surfaces, ir_string(operation, "surface") or ""), "prefix") or ""
|
|
72
|
+
)
|
|
73
|
+
template = self.mount + (ir_string(operation, "path") or "")
|
|
74
|
+
operations[operation_id] = operation
|
|
75
|
+
routes.append(ContractRoute.of(operation_id, ir_string(operation, "method") or "", template, base_path))
|
|
76
|
+
|
|
77
|
+
if base_path not in base_paths:
|
|
78
|
+
base_paths.append(base_path)
|
|
79
|
+
|
|
80
|
+
object.__setattr__(self, "_operations", operations)
|
|
81
|
+
object.__setattr__(self, "_routes", tuple(most_specific_first(routes)))
|
|
82
|
+
object.__setattr__(self, "_base_paths", tuple(base_paths))
|
|
83
|
+
object.__setattr__(self, "_header_entries", self._resolved_header_entries())
|
|
84
|
+
|
|
85
|
+
def _resolved_header_entries(self) -> dict[tuple[str, int], tuple[HeaderEntry | HeaderProblem, ...]]:
|
|
86
|
+
"""The derived header entries of each response, by operation and status, resolved once: a read's ETag looks
|
|
87
|
+
through every operation for its `If-Match` writes."""
|
|
88
|
+
operations = list(self._operations.values())
|
|
89
|
+
resolved: dict[tuple[str, int], tuple[HeaderEntry | HeaderProblem, ...]] = {}
|
|
90
|
+
|
|
91
|
+
for operation_id, operation in self._operations.items():
|
|
92
|
+
for response in ir_maps(operation, "responses"):
|
|
93
|
+
entries = (
|
|
94
|
+
response_header_entry(self.api, self.mount, operations, operation, header)
|
|
95
|
+
for header in ir_maps(response, "headers")
|
|
96
|
+
)
|
|
97
|
+
resolved[(operation_id, ir_int(response, "status") or 0)] = tuple(
|
|
98
|
+
entry for entry in entries if entry is not None
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return resolved
|
|
102
|
+
|
|
103
|
+
def response_header_entries(self, operation_id: str, status: int) -> tuple[HeaderEntry | HeaderProblem, ...]:
|
|
104
|
+
"""The entries the boundary writes the derived headers of the operation's `status` response from, or why it
|
|
105
|
+
cannot; empty for a response that derives none."""
|
|
106
|
+
return self._header_entries.get((operation_id, status), ())
|
|
107
|
+
|
|
108
|
+
def operation(self, operation_id: str) -> IrMap:
|
|
109
|
+
"""Raises `LookupError` for an operation the api does not keep."""
|
|
110
|
+
operation = self._operations.get(operation_id)
|
|
111
|
+
|
|
112
|
+
if operation is None:
|
|
113
|
+
raise LookupError(f"The api '{self.api}' has no operation '{operation_id}'.")
|
|
114
|
+
|
|
115
|
+
return operation
|
|
116
|
+
|
|
117
|
+
def part_members(self, operation_id: str, part: str) -> Mapping[str, str]:
|
|
118
|
+
"""The wire names of one part of the operation's input and their Python attributes, empty for a part it does not
|
|
119
|
+
have. Raises `ValueError` for a part name that is none of the known parts."""
|
|
120
|
+
if part not in _KNOWN_PARTS:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Unknown part '{part}' of an operation's input; the known parts are {', '.join(_KNOWN_PARTS)}."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self.operation(operation_id)
|
|
126
|
+
|
|
127
|
+
return self.operation_members.get(operation_id, {}).get(part, {})
|
|
128
|
+
|
|
129
|
+
def ignores_unknown_query(self, operation_id: str) -> bool:
|
|
130
|
+
"""Whether the operation ignores the query keys it does not declare, rather than refusing them."""
|
|
131
|
+
return ir_string(ir_map(self.operation(operation_id), "unknownQuery"), "policy") == _IGNORE_POLICY
|
|
132
|
+
|
|
133
|
+
def unknown_keys_policy(self, operation_id: str) -> str:
|
|
134
|
+
"""`refuse`, `ignore` or `keep`: what the operation's body does with a key its schema does not declare; the
|
|
135
|
+
names of the body's rule proofs are declared keys."""
|
|
136
|
+
input_use = ir_map(ir_map(self.operation(operation_id), "input"), "schema")
|
|
137
|
+
|
|
138
|
+
return ir_string(ir_map(input_use, "unknownKeys"), "policy") or _REFUSE_POLICY
|
|
139
|
+
|
|
140
|
+
def operation_ids(self) -> list[str]:
|
|
141
|
+
return list(self._operations)
|
|
142
|
+
|
|
143
|
+
def base_paths(self) -> tuple[str, ...]:
|
|
144
|
+
return self._base_paths
|
|
145
|
+
|
|
146
|
+
def match(self, method: str, raw_path: str) -> RouteMatch | None:
|
|
147
|
+
"""The operation the raw path matches; an unserved match for a path under a base path that no route serves
|
|
148
|
+
with this method; None for a path under none of the api's base paths."""
|
|
149
|
+
if not any(_under(raw_path, base_path) for base_path in self._base_paths):
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
route_method = _GET if method.upper() == _HEAD else method.upper()
|
|
153
|
+
segments = raw_path.split("/")[1:]
|
|
154
|
+
|
|
155
|
+
for route in self._routes:
|
|
156
|
+
parameters = route.match(segments)
|
|
157
|
+
|
|
158
|
+
if parameters is not None and route.method == route_method:
|
|
159
|
+
return RouteMatch(route.operation_id, parameters)
|
|
160
|
+
|
|
161
|
+
return RouteMatch(None, {})
|
|
162
|
+
|
|
163
|
+
def route_name(self, operation_id: str) -> str:
|
|
164
|
+
"""The name the generated route declaration gives an operation."""
|
|
165
|
+
return f"seampact.{self.api}.{operation_id}"
|
|
166
|
+
|
|
167
|
+
def role_code(self, operation_id: str | None, role: str) -> str | None:
|
|
168
|
+
"""The code an operation declares for `role`, else a catalogued code whose default role it is and that the
|
|
169
|
+
operation declares; with no operation, the first code any operation declares for the role."""
|
|
170
|
+
operations = list(self._operations.values()) if operation_id is None else [self.operation(operation_id)]
|
|
171
|
+
|
|
172
|
+
for operation in operations:
|
|
173
|
+
for error in ir_maps(operation, "errors"):
|
|
174
|
+
if any(ir_string(declared, "role") == role for declared in ir_maps(error, "roles")):
|
|
175
|
+
return ir_string(error, "code")
|
|
176
|
+
|
|
177
|
+
if operation_id is None:
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
for code, catalogued in ir_map(self.ir_view, "errors").items():
|
|
181
|
+
if ir_string(catalogued, "default") == role and self.declares(operation_id, code):
|
|
182
|
+
return code
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def role_params(self, operation_id: str, code: str, role: str) -> object:
|
|
187
|
+
"""The params template a role binds on one of the operation's codes, None when it binds none."""
|
|
188
|
+
for declared in ir_maps(self.error_declaration(operation_id, code), "roles"):
|
|
189
|
+
if ir_string(declared, "role") == role:
|
|
190
|
+
return declared.get("params")
|
|
191
|
+
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def error_declaration(self, operation_id: str, code: str) -> IrMap:
|
|
195
|
+
"""The operation's entry for `code` (its roles and retry header), empty when it declares none."""
|
|
196
|
+
for error in ir_maps(self.operation(operation_id), "errors"):
|
|
197
|
+
if ir_string(error, "code") == code:
|
|
198
|
+
return error
|
|
199
|
+
|
|
200
|
+
return {}
|
|
201
|
+
|
|
202
|
+
def declares(self, operation_id: str, code: str) -> bool:
|
|
203
|
+
return bool(self.error_declaration(operation_id, code))
|
|
204
|
+
|
|
205
|
+
def catalog_entry(self, code: str) -> IrMap:
|
|
206
|
+
return ir_map(ir_map(self.ir_view, "errors"), code)
|
|
207
|
+
|
|
208
|
+
def status_of(self, code: str) -> int:
|
|
209
|
+
return ir_int(ir_map(self.catalog_entry(code), "http"), "status") or INTERNAL_STATUS
|
|
210
|
+
|
|
211
|
+
def declared_codes(self, operation_id: str, status: int) -> list[str]:
|
|
212
|
+
"""The operation's codes answered with `status`."""
|
|
213
|
+
codes = [ir_string(error, "code") for error in ir_maps(self.operation(operation_id), "errors")]
|
|
214
|
+
|
|
215
|
+
return [code for code in codes if code is not None and self.status_of(code) == status]
|
|
216
|
+
|
|
217
|
+
def format(self, operation_id: str | None) -> IrMap:
|
|
218
|
+
"""The wire format an operation answers in; the api's first format when no operation matched."""
|
|
219
|
+
formats = ir_map(self.ir_view, "formats")
|
|
220
|
+
format_id = None if operation_id is None else ir_string(self.operation(operation_id), "format")
|
|
221
|
+
|
|
222
|
+
if format_id is None:
|
|
223
|
+
format_id = next(iter(formats), "")
|
|
224
|
+
|
|
225
|
+
return ir_map(formats, format_id)
|
|
226
|
+
|
|
227
|
+
def declaration(self, kind: str, declaration_id: str) -> IrMap:
|
|
228
|
+
return ir_map(ir_map(self.ir_view, kind), declaration_id)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _under(raw_path: str, base_path: str) -> bool:
|
|
232
|
+
return base_path == "" or raw_path == base_path or raw_path.startswith(base_path + "/")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""A CORS preflight: an `OPTIONS` request with `Origin` and `Access-Control-Request-Method`, which no contract
|
|
2
|
+
operation declares. A boundary leaves it to the app's CORS layer, as the PHP boundaries do, rather than answering the
|
|
3
|
+
path's 404 for a method the path does not serve."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
_PREFLIGHT_METHOD: Final = "OPTIONS"
|
|
9
|
+
_ORIGIN: Final = "origin"
|
|
10
|
+
_REQUESTED_METHOD: Final = "access-control-request-method"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_cors_preflight(method: str, header_names: Iterable[str]) -> bool:
|
|
14
|
+
"""`header_names` in any case; the method as sent, since HTTP methods are case-sensitive."""
|
|
15
|
+
if method != _PREFLIGHT_METHOD:
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
names = {name.lower() for name in header_names}
|
|
19
|
+
|
|
20
|
+
return _ORIGIN in names and _REQUESTED_METHOD in names
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The request deadline (grammar section 9, a header of semantic `deadline`): an RFC 3339 instant the caller sends in
|
|
2
|
+
the operation's `deadlineHeader`. The boundary refuses a malformed one with `schemaInvalid` and a passed one with the
|
|
3
|
+
`deadlineExceeded` role's code; the generated request's `deadline` gives the handler the time left. An operation's
|
|
4
|
+
`budgetMs` alone is the client's default timeout: the server receives nothing of it beside this header."""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
11
|
+
from seampact.codegen.runtime.failures import ContractRefusal
|
|
12
|
+
from seampact.codegen.runtime.input_decoding import DecodedInput, Issue, role_refusal, schema_invalid
|
|
13
|
+
from seampact.codegen.runtime.ir_view import ir_string
|
|
14
|
+
from seampact.codegen.runtime.value_rules import ValueRuleError, date_time_from_text
|
|
15
|
+
|
|
16
|
+
DEADLINE_EXCEEDED_ROLE: Final = "deadlineExceeded"
|
|
17
|
+
|
|
18
|
+
_HEADER_PART: Final = "header"
|
|
19
|
+
_FORMAT_RULE: Final = "format"
|
|
20
|
+
_MILLISECONDS_PER_SECOND: Final = 1000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class RequestDeadline:
|
|
25
|
+
"""The instant the caller stops waiting, aware of its offset."""
|
|
26
|
+
|
|
27
|
+
at: datetime
|
|
28
|
+
|
|
29
|
+
def remaining_ms(self, now: datetime) -> int:
|
|
30
|
+
"""The whole milliseconds left at `now`, 0 once the deadline has passed."""
|
|
31
|
+
return max(int((self.at - now).total_seconds() * _MILLISECONDS_PER_SECOND), 0)
|
|
32
|
+
|
|
33
|
+
def has_passed(self, now: datetime) -> bool:
|
|
34
|
+
return now >= self.at
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def request_deadline(
|
|
38
|
+
contract: ApiContract, operation_id: str, decoded: DecodedInput, now: datetime
|
|
39
|
+
) -> RequestDeadline | None:
|
|
40
|
+
"""The deadline the request carries, None when the operation reads none or the caller sent none (a required
|
|
41
|
+
header was refused by the input step).
|
|
42
|
+
|
|
43
|
+
Raises `ContractRefusal` for a malformed deadline, or one passed at `now`."""
|
|
44
|
+
header = ir_string(contract.operation(operation_id), "deadlineHeader")
|
|
45
|
+
sent = None if header is None else decoded.headers.get(header)
|
|
46
|
+
|
|
47
|
+
if header is None or sent is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
deadline = RequestDeadline(date_time_from_text(str(sent)))
|
|
52
|
+
except ValueRuleError:
|
|
53
|
+
raise schema_invalid(
|
|
54
|
+
contract, operation_id, [Issue(_HEADER_PART, f"/{header.lower()}", _FORMAT_RULE)]
|
|
55
|
+
) from None
|
|
56
|
+
|
|
57
|
+
if deadline.has_passed(now):
|
|
58
|
+
raise deadline_exceeded(contract, operation_id)
|
|
59
|
+
|
|
60
|
+
return deadline
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sent_deadline(text: str | None) -> RequestDeadline | None:
|
|
64
|
+
"""The deadline a header's text carries, None when the caller sent none.
|
|
65
|
+
|
|
66
|
+
Raises `ValueRuleError` for a text that is not an RFC 3339 instant with its offset."""
|
|
67
|
+
return None if text is None else RequestDeadline(date_time_from_text(text))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def deadline_exceeded(contract: ApiContract, operation_id: str) -> ContractRefusal:
|
|
71
|
+
"""The refusal a handler raises when the deadline passes while it works."""
|
|
72
|
+
return role_refusal(contract, operation_id, DEADLINE_EXCEEDED_ROLE)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The SHA-256 digests of the boundary (the throttle bucket key, and later the idempotency key and the strong ETag),
|
|
2
|
+
over the text `JSON.stringify` writes with object members sorted by name, so every target derives the same key from
|
|
3
|
+
the same values (ADR 0023 "Query and bounded values")."""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
# `JSON.stringify` writes an exponent from 1e21 up and below 1e-6.
|
|
13
|
+
_PLAIN_UPPER_EXPONENT: Final = 21
|
|
14
|
+
_PLAIN_LOWER_EXPONENT: Final = -6
|
|
15
|
+
# 2^32 - 1, the first integer that is not an array index in JavaScript.
|
|
16
|
+
_NOT_AN_INDEX: Final = 4294967295
|
|
17
|
+
_INDEX_NAME: Final = re.compile(r"(?:0|[1-9][0-9]{0,9})")
|
|
18
|
+
_DECIMAL_TEXT: Final = re.compile(r"(-?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?")
|
|
19
|
+
_UTF16: Final = "utf-16-be"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hex_of(parts: Sequence[object]) -> str:
|
|
23
|
+
"""The SHA-256 of the canonical JSON of `parts`, as lowercase hex."""
|
|
24
|
+
return hashlib.sha256(canonical(list(parts)).encode("utf-8", errors="surrogatepass")).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def canonical(value: object) -> str:
|
|
28
|
+
"""`value` as `JSON.stringify` writes it, object members sorted by name. Raises `TypeError` for a value JSON
|
|
29
|
+
cannot hold."""
|
|
30
|
+
if value is None:
|
|
31
|
+
return "null"
|
|
32
|
+
|
|
33
|
+
if isinstance(value, bool):
|
|
34
|
+
return "true" if value else "false"
|
|
35
|
+
|
|
36
|
+
if isinstance(value, int):
|
|
37
|
+
return str(value)
|
|
38
|
+
|
|
39
|
+
if isinstance(value, float):
|
|
40
|
+
return _number(value)
|
|
41
|
+
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
return json.dumps(value, ensure_ascii=False)
|
|
44
|
+
|
|
45
|
+
if isinstance(value, Mapping):
|
|
46
|
+
names = sorted((str(name) for name in value), key=_name_order)
|
|
47
|
+
members = {str(name): member for name, member in value.items()}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
"{" + ",".join(f"{json.dumps(name, ensure_ascii=False)}:{canonical(members[name])}" for name in names) + "}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if isinstance(value, list | tuple):
|
|
54
|
+
return "[" + ",".join(canonical(item) for item in value) + "]"
|
|
55
|
+
|
|
56
|
+
raise TypeError(f"seampact: a digest cannot read a {type(value).__qualname__}.")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _name_order(name: str) -> tuple[int, bytes]:
|
|
60
|
+
"""Array-index names first in numeric order, as every JavaScript object orders its own keys, then the others by
|
|
61
|
+
UTF-16 code units, as JavaScript's default sort compares them."""
|
|
62
|
+
index = min(int(name), _NOT_AN_INDEX) if _INDEX_NAME.fullmatch(name) else _NOT_AN_INDEX
|
|
63
|
+
|
|
64
|
+
return index, name.encode(_UTF16, errors="surrogatepass")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _number(value: float) -> str:
|
|
68
|
+
"""A number as JavaScript prints its double: the shortest digits, plain between 1e-6 and 1e21, an exponent
|
|
69
|
+
outside."""
|
|
70
|
+
if not math.isfinite(value):
|
|
71
|
+
return "null"
|
|
72
|
+
|
|
73
|
+
if value == 0:
|
|
74
|
+
return "0"
|
|
75
|
+
|
|
76
|
+
parts = _DECIMAL_TEXT.fullmatch(repr(value))
|
|
77
|
+
|
|
78
|
+
if parts is None:
|
|
79
|
+
raise ValueError(f"seampact: the number {value!r} has no decimal form.")
|
|
80
|
+
|
|
81
|
+
sign, integral, fraction, exponent_text = parts.group(1), parts.group(2), parts.group(3) or "", parts.group(4)
|
|
82
|
+
digits = (integral + fraction).lstrip("0")
|
|
83
|
+
leading_zeros = len(integral + fraction) - len(digits)
|
|
84
|
+
exponent = int(exponent_text or "0") + len(integral) - 1 - leading_zeros
|
|
85
|
+
digits = digits.rstrip("0")
|
|
86
|
+
|
|
87
|
+
if exponent >= _PLAIN_UPPER_EXPONENT or exponent < _PLAIN_LOWER_EXPONENT:
|
|
88
|
+
mantissa = digits if len(digits) == 1 else f"{digits[0]}.{digits[1:]}"
|
|
89
|
+
|
|
90
|
+
return f"{sign}{mantissa}e{'-' if exponent < 0 else '+'}{abs(exponent)}"
|
|
91
|
+
|
|
92
|
+
if exponent < 0:
|
|
93
|
+
return f"{sign}0.{'0' * (-exponent - 1)}{digits}"
|
|
94
|
+
|
|
95
|
+
whole = digits[: exponent + 1].ljust(exponent + 1, "0")
|
|
96
|
+
decimals = digits[exponent + 1 :]
|
|
97
|
+
|
|
98
|
+
return f"{sign}{whole}{'.' + decimals if decimals else ''}"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""What stops a contract request before its handler's reply: a refusal answered with one of the api's codes, and a
|
|
2
|
+
failure of the boundary itself, answered with the api's 500; and how a log line names a failure."""
|
|
3
|
+
|
|
4
|
+
import traceback
|
|
5
|
+
|
|
6
|
+
from seampact.codegen.runtime.ir_view import IrMap
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ContractRefusal(Exception): # noqa: N818 - a refusal is an answer, not an error of the app
|
|
10
|
+
"""A request the boundary answers with `code` of `api`, its params rendered from the code's role binding."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, api: str, code: str, params: IrMap | None = None) -> None:
|
|
13
|
+
super().__init__(f"seampact: the '{api}' boundary refuses the request with '{code}'.")
|
|
14
|
+
self.api = api
|
|
15
|
+
self.code = code
|
|
16
|
+
self.params = params
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BoundaryFailureError(RuntimeError):
|
|
20
|
+
"""A fault of the generated code or the handler that the boundary answers with the api's 500, logged with the
|
|
21
|
+
cause."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def failure_place(failure: BaseException) -> str:
|
|
25
|
+
"""The exception's class and where it was raised; the boundary's own failures name no input and keep their
|
|
26
|
+
message. Any other message may carry an input value, so it is left to the caller's debug mode."""
|
|
27
|
+
if isinstance(failure, BoundaryFailureError):
|
|
28
|
+
return str(failure)
|
|
29
|
+
|
|
30
|
+
frames = traceback.extract_tb(failure.__traceback__)
|
|
31
|
+
where = f"{frames[-1].filename}:{frames[-1].lineno}" if frames else "an unknown place"
|
|
32
|
+
|
|
33
|
+
return f"{type(failure).__qualname__} raised at {where}"
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Field rights (ADR 0012 "Field rights"): step 7 refuses a restricted top-level input member sent without its right,
|
|
2
|
+
or leaves it out under `inputPolicy: strip`; before a success is written, each restricted output member whose right
|
|
3
|
+
the decider denies for its holder is left out, one decision per right, use-site params, pointer and subject, and a
|
|
4
|
+
member the contract never sends on the operation (`always: absent`) is left out without a decision. Each returns a
|
|
5
|
+
copy; the tree it reads is never changed."""
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from seampact.codegen.runtime.access_steps import (
|
|
13
|
+
AccessHooks,
|
|
14
|
+
AccessState,
|
|
15
|
+
HookCaller,
|
|
16
|
+
decided,
|
|
17
|
+
extensions_of,
|
|
18
|
+
refusal_of,
|
|
19
|
+
subject_of,
|
|
20
|
+
subject_type,
|
|
21
|
+
)
|
|
22
|
+
from seampact.codegen.runtime.failures import ContractRefusal
|
|
23
|
+
from seampact.codegen.runtime.ir_view import IrMap, ir_map, ir_maps, ir_string
|
|
24
|
+
from seampact.codegen.runtime.replies import role_params
|
|
25
|
+
from seampact.codegen.runtime.requirement_params import requirement_params
|
|
26
|
+
from seampact.hooks import FieldDirection, RequirementKind, RequirementRequest, SubjectRef, decision, purpose
|
|
27
|
+
|
|
28
|
+
_INPUT: Final = "input"
|
|
29
|
+
_OUTPUT: Final = "output"
|
|
30
|
+
_STRIP: Final = "strip"
|
|
31
|
+
_ABSENT: Final = "absent"
|
|
32
|
+
_FORBIDDEN_ROLE: Final = "forbidden"
|
|
33
|
+
_HOLDER_FIELD: Final = "holderField"
|
|
34
|
+
_WILDCARD: Final = "*"
|
|
35
|
+
_TABLE_SHAPE: Final = "table"
|
|
36
|
+
_ITEMS_SHAPES: Final = frozenset({"list", "page"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def checked_input_fields(state: AccessState, hooks: AccessHooks, call: HookCaller) -> object:
|
|
40
|
+
"""The decoded body with each restricted member the decider denies left out under `strip`.
|
|
41
|
+
|
|
42
|
+
Raises `ContractRefusal` with the denial's code, its params joined by the `forbidden` role's `pointer`, for a
|
|
43
|
+
denied member under `reject`."""
|
|
44
|
+
if not isinstance(state.body, Mapping):
|
|
45
|
+
return state.body
|
|
46
|
+
|
|
47
|
+
body = copy.deepcopy(dict(state.body))
|
|
48
|
+
|
|
49
|
+
for field_right in ir_maps(state.contract.operation(state.operation_id), "fieldRights"):
|
|
50
|
+
if ir_string(field_right, "direction") != _INPUT:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
pointer = ir_string(field_right, "pointer") or ""
|
|
54
|
+
member = "/".join(_pointer_segments(pointer))
|
|
55
|
+
|
|
56
|
+
if member not in body:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
right = ir_string(field_right, "right") or ""
|
|
60
|
+
subject = subject_of(state, ir_map(field_right, "subject"), right)
|
|
61
|
+
answer = await decided(hooks, call, _request(state, field_right, subject, FieldDirection.INPUT))
|
|
62
|
+
|
|
63
|
+
if isinstance(answer, decision.Allowed):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if ir_string(field_right, "inputPolicy") == _STRIP:
|
|
67
|
+
del body[member]
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
raise _forbidden_field(state, answer, pointer)
|
|
71
|
+
|
|
72
|
+
return body
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def visible_output(
|
|
76
|
+
state: AccessState, hooks: AccessHooks, call: HookCaller, response: IrMap, wire_body: object
|
|
77
|
+
) -> object:
|
|
78
|
+
"""The success body as the wire writes it, without each restricted member its holder may not see."""
|
|
79
|
+
body = copy.deepcopy(wire_body)
|
|
80
|
+
decisions: dict[tuple[str, str, str, str], bool] = {}
|
|
81
|
+
body_schema = ir_string(ir_map(response, "body"), "schema")
|
|
82
|
+
|
|
83
|
+
for field_right in ir_maps(state.contract.operation(state.operation_id), "fieldRights"):
|
|
84
|
+
if ir_string(field_right, "direction") != _OUTPUT or ir_string(field_right, "schema") != body_schema:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
segments = _pointer_segments(ir_string(field_right, "pointer") or "")
|
|
88
|
+
member = segments.pop()
|
|
89
|
+
|
|
90
|
+
for instance in _instances(body, ir_string(response, "shape")):
|
|
91
|
+
for holder in _holders(instance, segments):
|
|
92
|
+
if member in holder and not await _allowed(state, hooks, call, field_right, holder, decisions):
|
|
93
|
+
del holder[member]
|
|
94
|
+
|
|
95
|
+
return body
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _allowed(
|
|
99
|
+
state: AccessState,
|
|
100
|
+
hooks: AccessHooks,
|
|
101
|
+
call: HookCaller,
|
|
102
|
+
field_right: IrMap,
|
|
103
|
+
holder: Mapping[str, object],
|
|
104
|
+
decisions: dict[tuple[str, str, str, str], bool],
|
|
105
|
+
) -> bool:
|
|
106
|
+
if ir_string(field_right, "always") == _ABSENT:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
right = ir_string(field_right, "right") or ""
|
|
110
|
+
source = ir_map(ir_map(field_right, "subject"), "source")
|
|
111
|
+
subject = None
|
|
112
|
+
|
|
113
|
+
if ir_string(source, "in") == _HOLDER_FIELD:
|
|
114
|
+
holder_id = holder.get(ir_string(source, "name") or "")
|
|
115
|
+
|
|
116
|
+
# A holder without a usable id names no subject: the decider is asked with none.
|
|
117
|
+
if isinstance(holder_id, str | int) and not isinstance(holder_id, bool):
|
|
118
|
+
subject = SubjectRef(type=subject_type(state.contract, right), id=holder_id)
|
|
119
|
+
elif source:
|
|
120
|
+
subject = subject_of(state, ir_map(field_right, "subject"), right)
|
|
121
|
+
|
|
122
|
+
# The decider sees the field's params and pointer, so both join the key.
|
|
123
|
+
use_site_params = json.dumps(field_right.get("params"), sort_keys=True)
|
|
124
|
+
pointer = ir_string(field_right, "pointer") or ""
|
|
125
|
+
key = (right, use_site_params, pointer, "" if subject is None else repr(subject.id))
|
|
126
|
+
|
|
127
|
+
if key not in decisions:
|
|
128
|
+
answer = await decided(hooks, call, _request(state, field_right, subject, FieldDirection.OUTPUT))
|
|
129
|
+
decisions[key] = isinstance(answer, decision.Allowed)
|
|
130
|
+
|
|
131
|
+
return decisions[key]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _pointer_segments(pointer: str) -> list[str]:
|
|
135
|
+
"""The member names a JSON pointer walks, `~1` and `~0` unescaped."""
|
|
136
|
+
return [segment.replace("~1", "/").replace("~0", "~") for segment in pointer[1:].split("/")]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _instances(body: object, shape: str | None) -> list[object]:
|
|
140
|
+
"""A table's values and a list's or page's items are each an instance of the body's schema."""
|
|
141
|
+
if shape == _TABLE_SHAPE:
|
|
142
|
+
return list(body.values()) if isinstance(body, dict) else []
|
|
143
|
+
|
|
144
|
+
if shape in _ITEMS_SHAPES:
|
|
145
|
+
return list(body) if isinstance(body, list) else []
|
|
146
|
+
|
|
147
|
+
return [body]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _holders(instance: object, segments: list[str]) -> list[dict[str, object]]:
|
|
151
|
+
"""The objects a pointer's parent segments reach, `*` standing for every item of an array."""
|
|
152
|
+
current = [instance]
|
|
153
|
+
|
|
154
|
+
for segment in segments:
|
|
155
|
+
reached: list[object] = []
|
|
156
|
+
|
|
157
|
+
for node in current:
|
|
158
|
+
if segment == _WILDCARD and isinstance(node, list):
|
|
159
|
+
reached.extend(node)
|
|
160
|
+
elif isinstance(node, dict) and segment in node:
|
|
161
|
+
reached.append(node[segment])
|
|
162
|
+
|
|
163
|
+
current = reached
|
|
164
|
+
|
|
165
|
+
return [node for node in current if isinstance(node, dict)]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _request(
|
|
169
|
+
state: AccessState, field_right: IrMap, subject: SubjectRef | None, direction: FieldDirection
|
|
170
|
+
) -> RequirementRequest:
|
|
171
|
+
name = ir_string(field_right, "right") or ""
|
|
172
|
+
operation = state.contract.operation(state.operation_id)
|
|
173
|
+
|
|
174
|
+
return RequirementRequest(
|
|
175
|
+
kind=RequirementKind.RIGHT,
|
|
176
|
+
name=name,
|
|
177
|
+
params=requirement_params(state.contract, RequirementKind.RIGHT, name, field_right),
|
|
178
|
+
principal=state.principal,
|
|
179
|
+
subject=subject,
|
|
180
|
+
scopes=tuple(state.scopes),
|
|
181
|
+
purpose=purpose.Field(pointer=ir_string(field_right, "pointer") or "", direction=direction),
|
|
182
|
+
operation=state.operation,
|
|
183
|
+
request=state.request,
|
|
184
|
+
extensions=RequirementRequest.Extensions(requirement={}, operation=extensions_of(operation)),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _forbidden_field(state: AccessState, denied: decision.Denied, pointer: str) -> ContractRefusal:
|
|
189
|
+
refusal = refusal_of(state, denied.deny)
|
|
190
|
+
rendered = role_params(state.contract, state.operation_id, refusal.code, _FORBIDDEN_ROLE, {"pointer": pointer})
|
|
191
|
+
params = {**(refusal.params or {}), **(rendered or {})}
|
|
192
|
+
|
|
193
|
+
return ContractRefusal(state.contract.api, refusal.code, params)
|