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,279 @@
|
|
|
1
|
+
"""Idempotency at the boundary (ADR 0012 "Target obligations"): after the input step, the claim's fixed-size key and
|
|
2
|
+
fingerprint, and the replay, running and mismatch answers; once the reply is written, the record completed with the
|
|
3
|
+
reply that went out, or abandoned after a server error or an error that forbids storing."""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import ClassVar, Final
|
|
7
|
+
|
|
8
|
+
from seampact.codegen.runtime.access_steps import HookCaller
|
|
9
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
10
|
+
from seampact.codegen.runtime.digests import hex_of
|
|
11
|
+
from seampact.codegen.runtime.failures import BoundaryFailureError
|
|
12
|
+
from seampact.codegen.runtime.input_decoding import DecodedInput, role_refusal
|
|
13
|
+
from seampact.codegen.runtime.ir_view import IrMap, ir_int, ir_map, ir_maps, ir_string, ir_strings
|
|
14
|
+
from seampact.codegen.runtime.principal_members import principal_members
|
|
15
|
+
from seampact.codegen.runtime.replies import CACHE_CONTROL, CONTENT_TYPE, NO_STORE, Reply
|
|
16
|
+
from seampact.hooks import IdempotencyClaim, IdempotencyStore, OperationRef, StoredResponse, idempotency_begin
|
|
17
|
+
from seampact.hooks._redaction import RedactedRepr
|
|
18
|
+
|
|
19
|
+
IN_PROGRESS_ROLE: Final = "idempotencyInProgress"
|
|
20
|
+
KEY_REUSED_ROLE: Final = "idempotencyKeyReused"
|
|
21
|
+
|
|
22
|
+
_DERIVED: Final = "derived"
|
|
23
|
+
_BOUNDARY: Final = "boundary"
|
|
24
|
+
_UNION_TAG_DERIVATION: Final = "union.headerTag"
|
|
25
|
+
_CONTENT_HEADER_PREFIX: Final = "content-"
|
|
26
|
+
_AUTHORIZATION_HEADER: Final = "authorization"
|
|
27
|
+
_REPLAYED: Final = "true"
|
|
28
|
+
_CLIENT_ERROR_FLOOR: Final = 400
|
|
29
|
+
_SERVER_ERROR_FLOOR: Final = 500
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class IdempotencyClaimed:
|
|
34
|
+
"""The request holds the record of `key`: its handler runs, then the placement settles the record."""
|
|
35
|
+
|
|
36
|
+
key: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
40
|
+
class IdempotencyReplayed(RedactedRepr):
|
|
41
|
+
"""The stored reply of an earlier request with the same key and input: the handler must not run. Its repr hides
|
|
42
|
+
the reply, whose body and headers the store keeps redacted too."""
|
|
43
|
+
|
|
44
|
+
_REDACTED_FIELDS: ClassVar[frozenset[str]] = frozenset({"reply"})
|
|
45
|
+
|
|
46
|
+
reply: Reply
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class IdempotencyStoreFailure:
|
|
51
|
+
"""The store failed after the reply was decided, so the reply stands: the placement logs `reason` with `cause`
|
|
52
|
+
and tells the unexpected-error listener."""
|
|
53
|
+
|
|
54
|
+
reason: str
|
|
55
|
+
cause: Exception
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
type IdempotencyStart = IdempotencyClaimed | IdempotencyReplayed | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def enforces_idempotency(contract: ApiContract, operation_id: str) -> bool:
|
|
62
|
+
"""Whether the boundary enforces the operation's idempotency, so it needs the api's idempotency store."""
|
|
63
|
+
idempotency = ir_map(contract.operation(operation_id), "idempotency")
|
|
64
|
+
|
|
65
|
+
return ir_string(idempotency, "kind") == _DERIVED and ir_string(idempotency, "enforce") == _BOUNDARY
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def begin_idempotency(
|
|
69
|
+
contract: ApiContract,
|
|
70
|
+
operation_id: str,
|
|
71
|
+
principal: object | None,
|
|
72
|
+
decoded: DecodedInput,
|
|
73
|
+
store: IdempotencyStore | None,
|
|
74
|
+
call: HookCaller,
|
|
75
|
+
) -> IdempotencyStart:
|
|
76
|
+
"""Claims the request's key. None for a request without a key, or an operation whose idempotency the boundary
|
|
77
|
+
does not enforce; a required key that is missing was refused by the input step.
|
|
78
|
+
|
|
79
|
+
Raises `ContractRefusal` when the key is running or was used with another request, and `BoundaryFailureError`
|
|
80
|
+
when the api has no store or the store answers something else."""
|
|
81
|
+
if not enforces_idempotency(contract, operation_id):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
idempotency = ir_map(contract.operation(operation_id), "idempotency")
|
|
85
|
+
|
|
86
|
+
client_key = decoded.headers.get(ir_string(idempotency, "header") or "")
|
|
87
|
+
|
|
88
|
+
if not isinstance(client_key, str):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
if store is None:
|
|
92
|
+
raise BoundaryFailureError(
|
|
93
|
+
f"seampact: the api '{contract.api}' has no idempotency store for operation '{operation_id}'; register "
|
|
94
|
+
"one as the target's guide says."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
key = idempotency_partition_key(contract, operation_id, principal, client_key)
|
|
98
|
+
claim = IdempotencyClaim(
|
|
99
|
+
key=key,
|
|
100
|
+
fingerprint=idempotency_fingerprint(contract, operation_id, decoded),
|
|
101
|
+
lifetime_ms=ir_int(idempotency, "lifetimeMs") or 0,
|
|
102
|
+
operation=OperationRef(api=contract.api, id=operation_id),
|
|
103
|
+
)
|
|
104
|
+
begun = await call(store.begin, claim)
|
|
105
|
+
|
|
106
|
+
if isinstance(begun, idempotency_begin.Started):
|
|
107
|
+
return IdempotencyClaimed(key)
|
|
108
|
+
|
|
109
|
+
if isinstance(begun, idempotency_begin.Running):
|
|
110
|
+
raise role_refusal(contract, operation_id, IN_PROGRESS_ROLE)
|
|
111
|
+
|
|
112
|
+
if isinstance(begun, idempotency_begin.Mismatch):
|
|
113
|
+
raise role_refusal(contract, operation_id, KEY_REUSED_ROLE)
|
|
114
|
+
|
|
115
|
+
if isinstance(begun, idempotency_begin.Replay):
|
|
116
|
+
return IdempotencyReplayed(_replayed(begun.response, ir_string(idempotency, "replayHeader")))
|
|
117
|
+
|
|
118
|
+
raise BoundaryFailureError(
|
|
119
|
+
f"seampact: the idempotency store answered a {type(begun).__qualname__}, none of Started, Running, Mismatch "
|
|
120
|
+
"and Replay; return one of them."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def settle_idempotency(
|
|
125
|
+
contract: ApiContract,
|
|
126
|
+
operation_id: str,
|
|
127
|
+
claimed: IdempotencyClaimed,
|
|
128
|
+
reply: Reply,
|
|
129
|
+
store: IdempotencyStore,
|
|
130
|
+
call: HookCaller,
|
|
131
|
+
) -> IdempotencyStoreFailure | None:
|
|
132
|
+
"""Completes the claimed record with the reply that went out, or abandons it when the reply is not kept."""
|
|
133
|
+
try:
|
|
134
|
+
if not _is_kept_for_replay(reply):
|
|
135
|
+
await call(store.abandon, claimed.key)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
stored = StoredResponse(
|
|
139
|
+
status=reply.status, headers=_stored_headers(contract, operation_id, reply), body=reply.body
|
|
140
|
+
)
|
|
141
|
+
await call(lambda response: store.complete(claimed.key, response), stored)
|
|
142
|
+
except Exception as failure: # The reply stands: the failure goes back to the placement.
|
|
143
|
+
return IdempotencyStoreFailure(
|
|
144
|
+
"the idempotency store failed to record the reply, so a retry with the same key may run again; check "
|
|
145
|
+
"the store's backend",
|
|
146
|
+
failure,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def abandon_idempotency(
|
|
153
|
+
claimed: IdempotencyClaimed, store: IdempotencyStore, call: HookCaller
|
|
154
|
+
) -> IdempotencyStoreFailure | None:
|
|
155
|
+
"""Releases the key without a reply to keep, after the request failed before its reply."""
|
|
156
|
+
try:
|
|
157
|
+
await call(store.abandon, claimed.key)
|
|
158
|
+
except Exception as failure: # The request already failed: the failure goes back to the placement.
|
|
159
|
+
return IdempotencyStoreFailure(
|
|
160
|
+
"the idempotency store failed to release a key after the request failed, so a retry waits for its "
|
|
161
|
+
"lifetime; check the store's backend",
|
|
162
|
+
failure,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def idempotency_partition_key(
|
|
169
|
+
contract: ApiContract, operation_id: str, principal: object | None, client_key: str
|
|
170
|
+
) -> str:
|
|
171
|
+
"""The stored key of a client key: the api, operation, identity and principal members partition it, so two
|
|
172
|
+
callers never share a record (ADR 0012)."""
|
|
173
|
+
members = principal_members(contract, operation_id, principal)
|
|
174
|
+
identity = ir_string(ir_map(contract.operation(operation_id), "access"), "identity") or ""
|
|
175
|
+
|
|
176
|
+
return hex_of([contract.api, operation_id, identity, "" if members is None else hex_of([members]), client_key])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def idempotency_fingerprint(contract: ApiContract, operation_id: str, decoded: DecodedInput) -> str:
|
|
180
|
+
"""The digest a retry under the same key must match: the method, the contract path and the decoded input, its
|
|
181
|
+
headers cut to the header params the author declared and a union tag, in lower case. Any other header param the
|
|
182
|
+
compiler derives (a trace id, a deadline, the key, CSRF) and every credential header change on a retry."""
|
|
183
|
+
operation = contract.operation(operation_id)
|
|
184
|
+
credentials = _credential_headers(contract, operation)
|
|
185
|
+
counted = [name for name in _fingerprint_headers(operation) if name not in credentials]
|
|
186
|
+
headers = {name.lower(): value for name, value in decoded.headers.items() if name.lower() in counted}
|
|
187
|
+
parts: dict[str, object] = {"path": decoded.path, "query": decoded.query, "headers": headers}
|
|
188
|
+
|
|
189
|
+
if ir_map(operation, "input"):
|
|
190
|
+
parts["body"] = decoded.body
|
|
191
|
+
|
|
192
|
+
return hex_of([(ir_string(operation, "method") or "").lower(), ir_string(operation, "path") or "", parts])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _replayed(stored: StoredResponse, replay_header: str | None) -> Reply:
|
|
196
|
+
"""The stored reply as it went out, authored by the boundary; a stored success carries the replay header."""
|
|
197
|
+
replay_name = None if replay_header is None else replay_header.lower()
|
|
198
|
+
headers = [(header.name.lower(), header.value) for header in stored.headers if header.name.lower() != replay_name]
|
|
199
|
+
|
|
200
|
+
if replay_name is not None and stored.status < _CLIENT_ERROR_FLOOR:
|
|
201
|
+
headers.append((replay_name, _REPLAYED))
|
|
202
|
+
|
|
203
|
+
return Reply(stored.status, tuple(headers), stored.body)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_kept_for_replay(reply: Reply) -> bool:
|
|
207
|
+
"""A server error is dropped, and so is an error reply its sender marked no-store; a no-store success is kept,
|
|
208
|
+
so a retry never runs the write twice."""
|
|
209
|
+
if reply.status >= _SERVER_ERROR_FLOOR:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
if reply.status < _CLIENT_ERROR_FLOOR:
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
directives = [
|
|
216
|
+
directive.strip().lower()
|
|
217
|
+
for name, value in reply.headers
|
|
218
|
+
if name == CACHE_CONTROL
|
|
219
|
+
for directive in value.split(",")
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
return NO_STORE not in directives
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _stored_headers(contract: ApiContract, operation_id: str, reply: Reply) -> tuple[StoredResponse.Header, ...]:
|
|
226
|
+
"""The contract reply only: its content headers and the headers the operation declares for its status. Headers
|
|
227
|
+
set beside it (session cookies, CORS, security headers) belong to the request that ran."""
|
|
228
|
+
declared = {
|
|
229
|
+
(ir_string(header, "name") or "").lower()
|
|
230
|
+
for response in ir_maps(contract.operation(operation_id), "responses")
|
|
231
|
+
if ir_int(response, "status") == reply.status
|
|
232
|
+
for header in ir_maps(response, "headers")
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return tuple(
|
|
236
|
+
StoredResponse.Header(name=name, value=value)
|
|
237
|
+
for name, value in reply.headers
|
|
238
|
+
if name == CONTENT_TYPE or name.startswith(_CONTENT_HEADER_PREFIX) or name in declared
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _fingerprint_headers(operation: IrMap) -> list[str]:
|
|
243
|
+
"""The header params a retry repeats: those the author declared, and a union tag."""
|
|
244
|
+
names: list[str] = []
|
|
245
|
+
|
|
246
|
+
for param in ir_maps(ir_map(operation, "params"), "header"):
|
|
247
|
+
derived_from = ir_map(param, "derivedFrom")
|
|
248
|
+
|
|
249
|
+
if not derived_from or ir_string(derived_from, "derivation") == _UNION_TAG_DERIVATION:
|
|
250
|
+
names.append((ir_string(param, "name") or "").lower())
|
|
251
|
+
|
|
252
|
+
return names
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _credential_headers(contract: ApiContract, operation: IrMap) -> list[str]:
|
|
256
|
+
"""The headers carrying a credential, lower case: a retry after a token or session refresh is the same request."""
|
|
257
|
+
access = ir_map(operation, "access")
|
|
258
|
+
names: list[str] = []
|
|
259
|
+
|
|
260
|
+
for scheme_id in ir_strings(access, "schemes"):
|
|
261
|
+
scheme = contract.declaration("schemes", scheme_id)
|
|
262
|
+
kind = ir_string(scheme, "kind")
|
|
263
|
+
|
|
264
|
+
if kind == "bearer":
|
|
265
|
+
names.append(_AUTHORIZATION_HEADER)
|
|
266
|
+
elif kind == "header":
|
|
267
|
+
names.append((ir_string(scheme, "name") or "").lower())
|
|
268
|
+
|
|
269
|
+
signed = ir_string(contract.declaration("identities", ir_string(access, "identity") or ""), "signed")
|
|
270
|
+
|
|
271
|
+
if signed is not None:
|
|
272
|
+
names.append((ir_string(contract.declaration("schemes", signed), "name") or "").lower())
|
|
273
|
+
|
|
274
|
+
csrf_header = ir_string(ir_map(operation, "csrf"), "header")
|
|
275
|
+
|
|
276
|
+
if csrf_header is not None:
|
|
277
|
+
names.append(csrf_header.lower())
|
|
278
|
+
|
|
279
|
+
return names
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Step 1's decoding: the path parameters percent-decoded from the raw path, the query from the raw query string, the
|
|
2
|
+
declared headers, and the body (bounded read, media type, `json` with objects and arrays kept apart); each placement
|
|
3
|
+
hands the decoded parts to its own step 6."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import AsyncIterator, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from seampact.codegen.runtime.body_reader import (
|
|
12
|
+
PAYLOAD_TOO_LARGE_ROLE,
|
|
13
|
+
SCHEMA_INVALID_ROLE,
|
|
14
|
+
UNSUPPORTED_MEDIA_TYPE_ROLE,
|
|
15
|
+
read_body,
|
|
16
|
+
)
|
|
17
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
18
|
+
from seampact.codegen.runtime.failures import BoundaryFailureError, ContractRefusal
|
|
19
|
+
from seampact.codegen.runtime.ir_view import ir_int, ir_map, ir_maps, ir_string, ir_true
|
|
20
|
+
from seampact.codegen.runtime.raw_target import RawTarget
|
|
21
|
+
from seampact.codegen.runtime.replies import role_params
|
|
22
|
+
from seampact.codegen.runtime.wire_values import decoded_segment, list_items, path_value, query_value, raw_query
|
|
23
|
+
from seampact.codegen.runtime.wire_values import scalar as scalar_value
|
|
24
|
+
|
|
25
|
+
_CONTENT_LENGTH: Final = "content-length"
|
|
26
|
+
_CONTENT_TYPE: Final = "content-type"
|
|
27
|
+
_CONTENT_ENCODING: Final = "content-encoding"
|
|
28
|
+
_TRANSFER_ENCODING: Final = "transfer-encoding"
|
|
29
|
+
_MAX_JSON_DEPTH: Final = 512
|
|
30
|
+
_LONE_SURROGATE: Final = re.compile("[\ud800-\udfff]")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class IncomingRequest:
|
|
35
|
+
"""What a placement reads of a contract request: header names in lower case, the first value of each."""
|
|
36
|
+
|
|
37
|
+
method: str
|
|
38
|
+
target: RawTarget
|
|
39
|
+
headers: Mapping[str, str]
|
|
40
|
+
body: AsyncIterator[bytes]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
44
|
+
class DecodedInput:
|
|
45
|
+
"""The params by name, decoded by the IR's wire form, and the body tree (None when the request carries none);
|
|
46
|
+
`raw_body` holds the bytes as received on a signed route only. `repr` shows no value."""
|
|
47
|
+
|
|
48
|
+
path: dict[str, object]
|
|
49
|
+
query: dict[str, object]
|
|
50
|
+
headers: dict[str, object]
|
|
51
|
+
body: object
|
|
52
|
+
raw_body: bytes | None
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return "DecodedInput([redacted])"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class Issue:
|
|
60
|
+
"""One refused input value: where (`path`, `query`, `header`, `body`), the JSON pointer and the contract rule."""
|
|
61
|
+
|
|
62
|
+
in_: str
|
|
63
|
+
pointer: str
|
|
64
|
+
rule: str
|
|
65
|
+
|
|
66
|
+
def as_slot(self) -> dict[str, str]:
|
|
67
|
+
return {"in": self.in_, "pointer": self.pointer, "rule": self.rule}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def decode_input(
|
|
71
|
+
contract: ApiContract, operation_id: str, raw_parameters: Mapping[str, str], request: IncomingRequest
|
|
72
|
+
) -> DecodedInput:
|
|
73
|
+
"""Raises `ContractRefusal` with the operation's code for a refused body or path value."""
|
|
74
|
+
operation = contract.operation(operation_id)
|
|
75
|
+
params = ir_map(operation, "params")
|
|
76
|
+
path: dict[str, object] = {}
|
|
77
|
+
|
|
78
|
+
for param in ir_maps(params, "path"):
|
|
79
|
+
name = ir_string(param, "name") or ""
|
|
80
|
+
raw = raw_parameters.get(name, "")
|
|
81
|
+
|
|
82
|
+
if decoded_segment(raw) is None:
|
|
83
|
+
raise schema_invalid(contract, operation_id, [Issue("path", f"/{name}", "type")])
|
|
84
|
+
|
|
85
|
+
path[name] = path_value(raw, ir_map(param, "type"))
|
|
86
|
+
|
|
87
|
+
headers: dict[str, object] = {}
|
|
88
|
+
|
|
89
|
+
for param in ir_maps(params, "header"):
|
|
90
|
+
name = ir_string(param, "name") or ""
|
|
91
|
+
value = request.headers.get(name.lower())
|
|
92
|
+
|
|
93
|
+
if value is not None:
|
|
94
|
+
headers[name] = scalar_value(value, ir_map(param, "type"))
|
|
95
|
+
|
|
96
|
+
query = _query(ir_maps(params, "query"), request.target.query)
|
|
97
|
+
input_ = ir_map(operation, "input")
|
|
98
|
+
body: object = None
|
|
99
|
+
raw_body: bytes | None = None
|
|
100
|
+
|
|
101
|
+
if input_ and _carries_body(request.headers):
|
|
102
|
+
data = await _body_bytes(contract, operation_id, input_, request)
|
|
103
|
+
raw_body = data if ir_true(input_, "rawBody") else None
|
|
104
|
+
body = None if data == b"" else _json_tree(contract, operation_id, data)
|
|
105
|
+
|
|
106
|
+
return DecodedInput(path, query, headers, body, raw_body)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def schema_invalid(contract: ApiContract, operation_id: str, issues: list[Issue]) -> ContractRefusal:
|
|
110
|
+
"""The operation's `schemaInvalid` refusal with its issues."""
|
|
111
|
+
return role_refusal(contract, operation_id, SCHEMA_INVALID_ROLE, {"issues": [issue.as_slot() for issue in issues]})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def role_refusal(
|
|
115
|
+
contract: ApiContract, operation_id: str, role: str, slots: Mapping[str, object] | None = None
|
|
116
|
+
) -> ContractRefusal:
|
|
117
|
+
"""Raises `BoundaryFailureError` when the operation declares no code for `role`, which the compiler derives."""
|
|
118
|
+
code = contract.role_code(operation_id, role)
|
|
119
|
+
|
|
120
|
+
if code is None:
|
|
121
|
+
raise BoundaryFailureError(
|
|
122
|
+
f"seampact: operation '{operation_id}' has no code for the role '{role}'; regenerate the api."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return ContractRefusal(contract.api, code, role_params(contract, operation_id, code, role, slots or {}))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _query(declared: list[Mapping[str, object]], raw: str) -> dict[str, object]:
|
|
129
|
+
by_name = {ir_string(param, "name") or "": param for param in declared}
|
|
130
|
+
query: dict[str, object] = {}
|
|
131
|
+
|
|
132
|
+
for name, raw_values in raw_query(raw).items():
|
|
133
|
+
param = by_name.get(name)
|
|
134
|
+
values = list_items(raw_values, None if param is None else param.get("wire"))
|
|
135
|
+
|
|
136
|
+
if param is None:
|
|
137
|
+
query[name] = values[0] if len(values) == 1 else values
|
|
138
|
+
else:
|
|
139
|
+
query[name] = query_value(values, ir_map(param, "type"))
|
|
140
|
+
|
|
141
|
+
return query
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _body_bytes(
|
|
145
|
+
contract: ApiContract, operation_id: str, input_: Mapping[str, object], request: IncomingRequest
|
|
146
|
+
) -> bytes:
|
|
147
|
+
# The bounded read comes first, so an oversized body answers 413 whatever its media type (ADR 0012).
|
|
148
|
+
limit_bytes = ir_int(input_, "bodyLimitBytes")
|
|
149
|
+
|
|
150
|
+
if limit_bytes is None:
|
|
151
|
+
raise BoundaryFailureError(f"seampact: operation '{operation_id}' has no body limit; regenerate the api.")
|
|
152
|
+
|
|
153
|
+
read = await read_body(
|
|
154
|
+
request.body, request.headers.get(_CONTENT_LENGTH), request.headers.get(_CONTENT_ENCODING), limit_bytes
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if read.refused_role == PAYLOAD_TOO_LARGE_ROLE:
|
|
158
|
+
raise role_refusal(contract, operation_id, PAYLOAD_TOO_LARGE_ROLE, {"limitBytes": limit_bytes})
|
|
159
|
+
|
|
160
|
+
media = (request.headers.get(_CONTENT_TYPE) or "").split(";", 1)[0].strip().lower()
|
|
161
|
+
|
|
162
|
+
if read.refused_role == UNSUPPORTED_MEDIA_TYPE_ROLE or (read.data != b"" and media != ir_string(input_, "media")):
|
|
163
|
+
raise role_refusal(contract, operation_id, UNSUPPORTED_MEDIA_TYPE_ROLE)
|
|
164
|
+
|
|
165
|
+
if read.refused_role is not None:
|
|
166
|
+
raise schema_invalid(contract, operation_id, [Issue("body", "", "type")])
|
|
167
|
+
|
|
168
|
+
return read.data
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _json_tree(contract: ApiContract, operation_id: str, data: bytes) -> object:
|
|
172
|
+
try:
|
|
173
|
+
tree: object = json.loads(data.decode("utf-8"), parse_constant=_refuse_constant)
|
|
174
|
+
except (ValueError, RecursionError):
|
|
175
|
+
raise schema_invalid(contract, operation_id, [Issue("body", "", "type")]) from None
|
|
176
|
+
|
|
177
|
+
if _depth(tree) > _MAX_JSON_DEPTH or _holds_lone_surrogate(tree):
|
|
178
|
+
raise schema_invalid(contract, operation_id, [Issue("body", "", "type")])
|
|
179
|
+
|
|
180
|
+
return tree
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _refuse_constant(name: str) -> object:
|
|
184
|
+
"""`NaN` and the infinities are not JSON."""
|
|
185
|
+
raise ValueError(f"{name} is not JSON")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _holds_lone_surrogate(tree: object) -> bool:
|
|
189
|
+
"""A string with a lone surrogate, in a value or a key, which is not Unicode text."""
|
|
190
|
+
pending = [tree]
|
|
191
|
+
|
|
192
|
+
while pending:
|
|
193
|
+
item = pending.pop()
|
|
194
|
+
|
|
195
|
+
if isinstance(item, str) and _LONE_SURROGATE.search(item) is not None:
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
if isinstance(item, dict):
|
|
199
|
+
pending.extend(item.keys())
|
|
200
|
+
pending.extend(item.values())
|
|
201
|
+
elif isinstance(item, list):
|
|
202
|
+
pending.extend(item)
|
|
203
|
+
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _depth(tree: object) -> int:
|
|
208
|
+
depth = 0
|
|
209
|
+
level = [tree]
|
|
210
|
+
|
|
211
|
+
while level:
|
|
212
|
+
containers = [item for item in level if isinstance(item, dict | list)]
|
|
213
|
+
|
|
214
|
+
if not containers:
|
|
215
|
+
return depth
|
|
216
|
+
|
|
217
|
+
depth += 1
|
|
218
|
+
level = [child for item in containers for child in (item.values() if isinstance(item, dict) else item)]
|
|
219
|
+
|
|
220
|
+
return depth
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _carries_body(headers: Mapping[str, str]) -> bool:
|
|
224
|
+
length = headers.get(_CONTENT_LENGTH)
|
|
225
|
+
|
|
226
|
+
return (length is not None and length != "0") or _TRANSFER_ENCODING in headers
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Typed reads of the IR view the generated code holds as JSON values: a missing or mistyped member reads as an
|
|
2
|
+
empty map or list, or None, so the boundary never indexes past a member the contract does not carry."""
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
type IrMap = Mapping[str, object]
|
|
7
|
+
type IrJson = bool | int | float | str | list[IrJson] | dict[str, IrJson] | None
|
|
8
|
+
"""A JSON value as the generated code writes it, so a type checker reads each empty object or array it holds."""
|
|
9
|
+
|
|
10
|
+
_EMPTY: IrMap = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ir_map(node: object, key: str) -> IrMap:
|
|
14
|
+
value = node.get(key) if isinstance(node, Mapping) else None
|
|
15
|
+
|
|
16
|
+
return value if isinstance(value, Mapping) else _EMPTY
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ir_list(node: object, key: str) -> Sequence[object]:
|
|
20
|
+
value = node.get(key) if isinstance(node, Mapping) else None
|
|
21
|
+
|
|
22
|
+
return value if isinstance(value, list | tuple) else ()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ir_maps(node: object, key: str) -> list[IrMap]:
|
|
26
|
+
"""Each member of the list that is a map."""
|
|
27
|
+
return [item for item in ir_list(node, key) if isinstance(item, Mapping)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ir_string(node: object, key: str) -> str | None:
|
|
31
|
+
value = node.get(key) if isinstance(node, Mapping) else None
|
|
32
|
+
|
|
33
|
+
return value if isinstance(value, str) else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ir_strings(node: object, key: str) -> list[str]:
|
|
37
|
+
return [item for item in ir_list(node, key) if isinstance(item, str)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def ir_int(node: object, key: str) -> int | None:
|
|
41
|
+
value = node.get(key) if isinstance(node, Mapping) else None
|
|
42
|
+
|
|
43
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ir_true(node: object, key: str) -> bool:
|
|
47
|
+
return isinstance(node, Mapping) and node.get(key) is True
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""seampact's operation objects and components a `nativeDocs` placement carries (ADR 0026 "Native docs"), read from the
|
|
2
|
+
generated `native_docs.json`, and how they join the framework's own OpenAPI document (FastAPI's, Django Ninja's): each
|
|
3
|
+
contract operation at seampact's path under the entry's `mount`, and seampact's components beside the app's; a
|
|
4
|
+
component the app's document already holds with another value is refused, naming it."""
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
_OPERATIONS: Final = "operations"
|
|
14
|
+
_COMPONENTS: Final = "components"
|
|
15
|
+
_PATH: Final = "path"
|
|
16
|
+
_METHOD: Final = "method"
|
|
17
|
+
_OPERATION: Final = "operation"
|
|
18
|
+
|
|
19
|
+
type JsonObject = dict[str, object]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class DocumentJoin:
|
|
24
|
+
"""Whose document the api's operations join, as a refusal names them: the framework's and the api's names."""
|
|
25
|
+
|
|
26
|
+
framework: str
|
|
27
|
+
api: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class NativeOperation:
|
|
32
|
+
"""seampact's operation object of one contract operation, with the path and lower-case method it sits under in
|
|
33
|
+
seampact's document."""
|
|
34
|
+
|
|
35
|
+
path: str
|
|
36
|
+
method: str
|
|
37
|
+
operation: JsonObject
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class NativeDocs:
|
|
42
|
+
"""What `nativeDocs` adds to FastAPI's document: each kept operation's object by operation id, and seampact's
|
|
43
|
+
components by kind and name."""
|
|
44
|
+
|
|
45
|
+
operations: Mapping[str, NativeOperation]
|
|
46
|
+
components: Mapping[str, JsonObject]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NativeDocsError(Exception):
|
|
50
|
+
"""The framework's document and seampact's name one component with two values, or the generated file does not
|
|
51
|
+
read."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_native_docs(file: Path) -> NativeDocs:
|
|
55
|
+
"""The generated `native_docs.json` beside the placement module."""
|
|
56
|
+
document = json_object(json.loads(file.read_text(encoding="utf-8")), file.name)
|
|
57
|
+
operations: dict[str, NativeOperation] = {}
|
|
58
|
+
|
|
59
|
+
for operation_id, item in json_object(document.get(_OPERATIONS), _OPERATIONS).items():
|
|
60
|
+
native = json_object(item, operation_id)
|
|
61
|
+
operations[operation_id] = NativeOperation(
|
|
62
|
+
_text_member(native, _PATH, operation_id),
|
|
63
|
+
_text_member(native, _METHOD, operation_id),
|
|
64
|
+
json_object(native.get(_OPERATION), operation_id),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
components = {
|
|
68
|
+
kind: json_object(named, kind) for kind, named in json_object(document.get(_COMPONENTS), _COMPONENTS).items()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return NativeDocs(operations, components)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _text_member(native: JsonObject, key: str, operation_id: str) -> str:
|
|
75
|
+
value = native.get(key)
|
|
76
|
+
|
|
77
|
+
if not isinstance(value, str):
|
|
78
|
+
raise NativeDocsError(
|
|
79
|
+
f"seampact: the operation '{operation_id}' of native_docs.json has no '{key}'; run `seampact generate`."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def json_object(value: object, where: str) -> JsonObject:
|
|
86
|
+
if not isinstance(value, dict):
|
|
87
|
+
raise NativeDocsError(f"seampact: '{where}' of the OpenAPI document is not an object; run `seampact generate`.")
|
|
88
|
+
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def require_every_operation(operation_ids: Iterable[str], native_docs: NativeDocs) -> None:
|
|
93
|
+
"""An operation `native_docs.json` lacks means the file is stale or edited: fail at boot, not in the document."""
|
|
94
|
+
for operation_id in operation_ids:
|
|
95
|
+
if operation_id not in native_docs.operations:
|
|
96
|
+
raise NativeDocsError(
|
|
97
|
+
f"seampact: native_docs.json holds no operation '{operation_id}', which the api serves; "
|
|
98
|
+
"run `seampact generate`."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def add_native_docs(
|
|
103
|
+
document: JsonObject, operation_ids: Iterable[str], mount: str, native_docs: NativeDocs, joined: DocumentJoin
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Each operation at seampact's path under `mount`, with the contract's parameter names, then the components."""
|
|
106
|
+
paths = _member(document, "paths")
|
|
107
|
+
|
|
108
|
+
for operation_id in operation_ids:
|
|
109
|
+
native = native_docs.operations[operation_id]
|
|
110
|
+
_member(paths, mount.rstrip("/") + native.path)[native.method] = copy.deepcopy(native.operation)
|
|
111
|
+
|
|
112
|
+
components = _member(document, _COMPONENTS)
|
|
113
|
+
|
|
114
|
+
for kind, named in native_docs.components.items():
|
|
115
|
+
held = _member(components, kind)
|
|
116
|
+
|
|
117
|
+
for name, value in named.items():
|
|
118
|
+
if name in held and held[name] != value:
|
|
119
|
+
raise NativeDocsError(
|
|
120
|
+
f"seampact: {joined.framework}'s document already holds the component {kind} '{name}', and api "
|
|
121
|
+
f"'{joined.api}' declares another one under that name. Rename the app's model, or switch "
|
|
122
|
+
"`nativeDocs` off."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
held[name] = copy.deepcopy(value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _member(document: JsonObject, key: str) -> JsonObject:
|
|
129
|
+
"""The object `document` holds at `key`, added empty when absent."""
|
|
130
|
+
return json_object(document.setdefault(key, {}), key)
|