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,144 @@
|
|
|
1
|
+
"""Writes every contract reply as status, headers and bytes: a contract error in the api's format, a role's code, the
|
|
2
|
+
api's 500, and a declared success body in its format's envelope, serialized by the boundary's own JSON encoding,
|
|
3
|
+
never a framework's renderer."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from seampact.codegen.runtime.contract import INTERNAL_STATUS, UNDECLARED_STATUS_ROLE, ApiContract
|
|
12
|
+
from seampact.codegen.runtime.ir_view import IrMap, ir_list, ir_map, ir_string
|
|
13
|
+
from seampact.codegen.runtime.templates import render
|
|
14
|
+
|
|
15
|
+
JSON_MEDIA: Final = "application/json"
|
|
16
|
+
CONTENT_TYPE: Final = "content-type"
|
|
17
|
+
CACHE_CONTROL: Final = "cache-control"
|
|
18
|
+
NO_STORE: Final = "no-store"
|
|
19
|
+
|
|
20
|
+
_NULL_ABSENT: Final = "null"
|
|
21
|
+
_PAYLOAD_SLOT: Final = "payload"
|
|
22
|
+
_SECONDS_UNIT: Final = "s"
|
|
23
|
+
_MILLISECONDS_PER_SECOND: Final = 1000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Reply:
|
|
28
|
+
"""A reply the boundary writes; header names are lower case."""
|
|
29
|
+
|
|
30
|
+
status: int
|
|
31
|
+
headers: tuple[tuple[str, str], ...]
|
|
32
|
+
body: bytes
|
|
33
|
+
|
|
34
|
+
def header(self, name: str) -> str | None:
|
|
35
|
+
lowered = name.lower()
|
|
36
|
+
|
|
37
|
+
return next((value for key, value in self.headers if key == lowered), None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def json_bytes(value: object) -> bytes:
|
|
41
|
+
"""The boundary's JSON: UTF-8, no escaped slashes or non-ASCII, `1.0` kept; NaN and infinities refused. A lone
|
|
42
|
+
surrogate echoed from a request can only sit in a JSON string, so it is written as its `\\uXXXX` escape."""
|
|
43
|
+
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
44
|
+
|
|
45
|
+
return text.encode("utf-8", errors="backslashreplace")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def error_reply(
|
|
49
|
+
contract: ApiContract, operation_id: str | None, code: str, params: Mapping[str, object] | None = None
|
|
50
|
+
) -> Reply:
|
|
51
|
+
"""The reply of `code` in the operation's format, or the api's format when no operation matched."""
|
|
52
|
+
format_ = contract.format(operation_id)
|
|
53
|
+
catalogued = contract.catalog_entry(code)
|
|
54
|
+
status = contract.status_of(code)
|
|
55
|
+
slots: dict[str, object] = {"code": code, "status": status, "nature": ir_string(catalogued, "nature")}
|
|
56
|
+
title = ir_string(catalogued, "title")
|
|
57
|
+
|
|
58
|
+
if title is not None:
|
|
59
|
+
slots["title"] = title
|
|
60
|
+
|
|
61
|
+
if params is not None:
|
|
62
|
+
slots["params"] = params
|
|
63
|
+
|
|
64
|
+
error = ir_map(format_, "error")
|
|
65
|
+
body = render(error.get("body"), slots, ir_string(format_, "absent") == _NULL_ABSENT)
|
|
66
|
+
headers = [(CONTENT_TYPE, ir_string(error, "media") or JSON_MEDIA)]
|
|
67
|
+
|
|
68
|
+
if catalogued.get("noStore") is True:
|
|
69
|
+
headers.append((CACHE_CONTROL, NO_STORE))
|
|
70
|
+
|
|
71
|
+
if operation_id is not None:
|
|
72
|
+
headers.extend(_retry_header(contract.error_declaration(operation_id, code), params))
|
|
73
|
+
|
|
74
|
+
return Reply(status, tuple(headers), json_bytes(body.value if body.present else {}))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def role_params(
|
|
78
|
+
contract: ApiContract, operation_id: str, code: str, role: str, slots: Mapping[str, object]
|
|
79
|
+
) -> Mapping[str, object] | None:
|
|
80
|
+
"""The params a role's template renders from `slots` (`issues`, `limitBytes`), None when the code binds none."""
|
|
81
|
+
template = contract.role_params(operation_id, code, role)
|
|
82
|
+
|
|
83
|
+
if template is None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
rendered = render(template, slots, write_nulls=False)
|
|
87
|
+
|
|
88
|
+
return rendered.value if isinstance(rendered.value, Mapping) else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def role_reply(contract: ApiContract, operation_id: str | None, role: str, bare_status: int) -> Reply:
|
|
92
|
+
"""The role's code in the api's format, or a bare status when the api declares no code for the role."""
|
|
93
|
+
code = contract.role_code(operation_id, role)
|
|
94
|
+
|
|
95
|
+
return Reply(bare_status, (), b"") if code is None else error_reply(contract, operation_id, code)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def internal_error_reply(contract: ApiContract, operation_id: str | None) -> Reply:
|
|
99
|
+
"""The api's 500; the placement logs the cause and tells the unexpected-error listener."""
|
|
100
|
+
return role_reply(contract, operation_id, UNDECLARED_STATUS_ROLE, INTERNAL_STATUS)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def success_body(format_: IrMap, shape: str, payload: object, status: int, path: str) -> object:
|
|
104
|
+
"""The success body in its format's envelope (ADR 0002 section 13): the payload itself under a bare format.
|
|
105
|
+
|
|
106
|
+
Raises `LookupError` when the envelope renders nothing, which generation refuses."""
|
|
107
|
+
template = ir_map(ir_map(format_, "success"), shape).get("body")
|
|
108
|
+
|
|
109
|
+
if template is None or _is_bare_payload(template):
|
|
110
|
+
return payload
|
|
111
|
+
|
|
112
|
+
slots = {_PAYLOAD_SLOT: payload, "status": status, "path": path}
|
|
113
|
+
rendered = render(template, slots, ir_string(format_, "absent") == _NULL_ABSENT)
|
|
114
|
+
|
|
115
|
+
if not rendered.present:
|
|
116
|
+
raise LookupError(
|
|
117
|
+
f"seampact: the format's '{shape}' success envelope rendered nothing; regenerate the api, whose "
|
|
118
|
+
"generation refuses an envelope that does not write the payload under named members."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return rendered.value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_bare_payload(template: object) -> bool:
|
|
125
|
+
return (
|
|
126
|
+
ir_string(template, "kind") == "slot"
|
|
127
|
+
and ir_string(template, "slot") == _PAYLOAD_SLOT
|
|
128
|
+
and not ir_list(template, "path")
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _retry_header(declared: IrMap, params: Mapping[str, object] | None) -> list[tuple[str, str]]:
|
|
133
|
+
"""The delay param of a refusal, in integer milliseconds, written in its retry header's unit."""
|
|
134
|
+
retry = ir_map(declared, "retryHeader")
|
|
135
|
+
name = ir_string(retry, "name")
|
|
136
|
+
delay_ms = None if params is None else params.get(ir_string(retry, "param") or "")
|
|
137
|
+
|
|
138
|
+
if name is None or isinstance(delay_ms, bool) or not isinstance(delay_ms, int | float):
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
if ir_string(retry, "unit") == _SECONDS_UNIT:
|
|
142
|
+
return [(name.lower(), str(math.ceil(delay_ms / _MILLISECONDS_PER_SECOND)))]
|
|
143
|
+
|
|
144
|
+
return [(name.lower(), str(int(delay_ms)))]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Step 10, the outgoing response check (ADR 0016), on a reply the placement has in hand: a reply the boundary wrote
|
|
2
|
+
passes; a success it did not write is a violation; a declared error status from another writer is held up to
|
|
3
|
+
`MAX_CHECKED_ERROR_BODY_BYTES` and must carry one of the operation's codes for that status in the api's format; any
|
|
4
|
+
other status is a violation. The verdict strips an ETag the boundary did not set and applies the `responses` mode."""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import zlib
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
15
|
+
from seampact.codegen.runtime.ir_view import ir_list, ir_map, ir_maps, ir_string
|
|
16
|
+
from seampact.codegen.runtime.replies import CONTENT_TYPE, JSON_MEDIA, Reply, internal_error_reply
|
|
17
|
+
|
|
18
|
+
MAX_CHECKED_ERROR_BODY_BYTES: Final = 65536
|
|
19
|
+
|
|
20
|
+
_ERROR_FLOOR: Final = 400
|
|
21
|
+
_ETAG: Final = "etag"
|
|
22
|
+
_CONTENT_ENCODING: Final = "content-encoding"
|
|
23
|
+
_HEAD: Final = "HEAD"
|
|
24
|
+
# `wbits` of zlib: a gzip header, and a zlib header for HTTP's `deflate`.
|
|
25
|
+
_GZIP_WINDOW: Final = 16 + zlib.MAX_WBITS
|
|
26
|
+
_DEFLATE_WINDOW: Final = zlib.MAX_WBITS
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ResponsesMode(StrEnum):
|
|
30
|
+
STRICT = "strict"
|
|
31
|
+
WARN = "warn"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FrameworkEtagMode(StrEnum):
|
|
35
|
+
STRIP = "strip"
|
|
36
|
+
KEEP = "keep"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class OutgoingReply:
|
|
41
|
+
"""A reply as it leaves the app: lower-case header names; `body` None when it was not held (a stream, or past
|
|
42
|
+
`MAX_CHECKED_ERROR_BODY_BYTES`)."""
|
|
43
|
+
|
|
44
|
+
status: int
|
|
45
|
+
headers: tuple[tuple[str, str], ...]
|
|
46
|
+
body: bytes | None
|
|
47
|
+
|
|
48
|
+
def header(self, name: str) -> str | None:
|
|
49
|
+
return next((value for key, value in self.headers if key == name), None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class AuthoredReply:
|
|
54
|
+
status: int
|
|
55
|
+
body_digest: bytes
|
|
56
|
+
etag: str | None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class AuthoredReplies:
|
|
61
|
+
"""The replies the boundary wrote for one request, kept in the request's state."""
|
|
62
|
+
|
|
63
|
+
replies: list[AuthoredReply] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
def author(self, reply: Reply) -> Reply:
|
|
66
|
+
self.replies.append(AuthoredReply(reply.status, _digest(reply.body), reply.header(_ETAG)))
|
|
67
|
+
|
|
68
|
+
return reply
|
|
69
|
+
|
|
70
|
+
def find(self, reply: OutgoingReply, method: str) -> AuthoredReply | None:
|
|
71
|
+
"""The written reply with this status and body; a HEAD reply whose body the server emptied matches too."""
|
|
72
|
+
if reply.body is None:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
digest = _digest(reply.body)
|
|
76
|
+
|
|
77
|
+
for written in self.replies:
|
|
78
|
+
body_kept = written.body_digest == digest or (reply.body == b"" and method.upper() == _HEAD)
|
|
79
|
+
|
|
80
|
+
if written.status == reply.status and body_kept:
|
|
81
|
+
return written
|
|
82
|
+
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class Verdict:
|
|
88
|
+
"""The reply to send; `violation` names what the check found, `replaced` tells the api's 500 took its place."""
|
|
89
|
+
|
|
90
|
+
reply: OutgoingReply
|
|
91
|
+
violation: str | None
|
|
92
|
+
replaced: bool
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def verdict(
|
|
96
|
+
contract: ApiContract,
|
|
97
|
+
operation_id: str,
|
|
98
|
+
reply: OutgoingReply,
|
|
99
|
+
*,
|
|
100
|
+
authored: AuthoredReplies,
|
|
101
|
+
method: str,
|
|
102
|
+
admitted: bool,
|
|
103
|
+
responses: ResponsesMode,
|
|
104
|
+
framework_etag: FrameworkEtagMode,
|
|
105
|
+
) -> Verdict:
|
|
106
|
+
"""Strips a foreign ETag, then judges the reply: under `strict` a violation is replaced by the api's 500, under
|
|
107
|
+
`warn` it goes out as written. The placement logs a violation and tells the unexpected-error listener."""
|
|
108
|
+
written = authored.find(reply, method)
|
|
109
|
+
checked = _without_foreign_etag(reply, written, framework_etag)
|
|
110
|
+
violation = None if written is not None else foreign_reply_violation(contract, operation_id, checked, admitted)
|
|
111
|
+
|
|
112
|
+
if violation is None or responses is ResponsesMode.WARN:
|
|
113
|
+
return Verdict(checked, violation, replaced=False)
|
|
114
|
+
|
|
115
|
+
internal = internal_error_reply(contract, operation_id)
|
|
116
|
+
|
|
117
|
+
return Verdict(OutgoingReply(internal.status, internal.headers, internal.body), violation, replaced=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def foreign_reply_violation(
|
|
121
|
+
contract: ApiContract, operation_id: str, reply: OutgoingReply, admitted: bool
|
|
122
|
+
) -> str | None:
|
|
123
|
+
"""The violation of a reply the boundary did not write, None when it passes."""
|
|
124
|
+
status = reply.status
|
|
125
|
+
|
|
126
|
+
if status < _ERROR_FLOOR:
|
|
127
|
+
return f"a {status} reply the boundary did not write"
|
|
128
|
+
|
|
129
|
+
codes = contract.declared_codes(operation_id, status)
|
|
130
|
+
|
|
131
|
+
if not codes:
|
|
132
|
+
return f"the undeclared status {status}"
|
|
133
|
+
|
|
134
|
+
# Once admitted, only the boundary or a contract exception's rendering writes the reply.
|
|
135
|
+
if admitted:
|
|
136
|
+
return f"a {status} reply written after the boundary's access step admitted the request"
|
|
137
|
+
|
|
138
|
+
return _error_body_violation(contract, operation_id, reply, codes)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _error_body_violation(
|
|
142
|
+
contract: ApiContract, operation_id: str, reply: OutgoingReply, codes: Sequence[str]
|
|
143
|
+
) -> str | None:
|
|
144
|
+
status = reply.status
|
|
145
|
+
error_format = ir_map(contract.format(operation_id), "error")
|
|
146
|
+
error_media = (ir_string(error_format, "media") or JSON_MEDIA).lower()
|
|
147
|
+
media = (reply.header(CONTENT_TYPE) or "").split(";", 1)[0].strip().lower()
|
|
148
|
+
|
|
149
|
+
if reply.body is None:
|
|
150
|
+
return f"a {status} error body that cannot be checked"
|
|
151
|
+
|
|
152
|
+
if media != error_media:
|
|
153
|
+
return f"a {status} error body that is not {error_media}"
|
|
154
|
+
|
|
155
|
+
coding = (reply.header(_CONTENT_ENCODING) or "").strip().lower()
|
|
156
|
+
content = _decoded(reply.body, coding)
|
|
157
|
+
|
|
158
|
+
if content is None:
|
|
159
|
+
return f"a {status} error body in the content coding '{coding}', which the boundary cannot decode"
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
body = json.loads(content)
|
|
163
|
+
except ValueError:
|
|
164
|
+
return f"a {status} error body that is not JSON"
|
|
165
|
+
|
|
166
|
+
code_member = _code_member(error_format)
|
|
167
|
+
code = body.get(code_member) if isinstance(body, dict) and code_member is not None else None
|
|
168
|
+
|
|
169
|
+
return None if isinstance(code, str) and code in codes else f"a {status} error body without a declared code"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _code_member(error_format: object) -> str | None:
|
|
173
|
+
"""The top-level member of the format's error body that the `code` slot fills."""
|
|
174
|
+
for member in ir_maps(ir_map(error_format, "body"), "members"):
|
|
175
|
+
value = ir_map(member, "value")
|
|
176
|
+
|
|
177
|
+
if ir_string(value, "kind") == "slot" and ir_string(value, "slot") == "code" and not ir_list(value, "path"):
|
|
178
|
+
return ir_string(ir_map(member, "name"), "text")
|
|
179
|
+
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _decoded(body: bytes, coding: str) -> bytes | None:
|
|
184
|
+
"""The body without its content coding; None for a coding the standard library cannot decode (`br`) or a
|
|
185
|
+
corrupt body."""
|
|
186
|
+
if coding in ("", "identity"):
|
|
187
|
+
return body
|
|
188
|
+
|
|
189
|
+
window = {"gzip": _GZIP_WINDOW, "x-gzip": _GZIP_WINDOW, "deflate": _DEFLATE_WINDOW}.get(coding)
|
|
190
|
+
|
|
191
|
+
if window is None:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
return zlib.decompress(body, window)
|
|
196
|
+
except zlib.error:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _without_foreign_etag(
|
|
201
|
+
reply: OutgoingReply, written: AuthoredReply | None, mode: FrameworkEtagMode
|
|
202
|
+
) -> OutgoingReply:
|
|
203
|
+
etag = reply.header(_ETAG)
|
|
204
|
+
own_etag = None if written is None else written.etag
|
|
205
|
+
|
|
206
|
+
if etag is None or etag == own_etag or mode is FrameworkEtagMode.KEEP:
|
|
207
|
+
return reply
|
|
208
|
+
|
|
209
|
+
headers = tuple((name, value) for name, value in reply.headers if name != _ETAG)
|
|
210
|
+
|
|
211
|
+
return OutgoingReply(reply.status, headers, reply.body)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _digest(body: bytes) -> bytes:
|
|
215
|
+
return hashlib.sha256(body).digest()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The params a decide request carries: the use site's values as the right's or rule's generated params class, a
|
|
2
|
+
Pydantic model validated in strict mode, or a frozen dataclass built from the values checked against the params schema
|
|
3
|
+
(types, enum values, refinements, item counts, required and unknown members); None for a right or rule that declares
|
|
4
|
+
no params. Values the class does not hold fail the boundary before the decider runs."""
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import dataclasses
|
|
8
|
+
from typing import Final, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
11
|
+
from seampact.codegen.runtime.failures import BoundaryFailureError
|
|
12
|
+
from seampact.codegen.runtime.hinted_values import built_dataclass
|
|
13
|
+
from seampact.codegen.runtime.ir_view import IrMap, ir_int, ir_map, ir_maps, ir_string, ir_strings, ir_true
|
|
14
|
+
from seampact.codegen.runtime.value_rules import TYPE_RULE, ValueRuleError, check_length, checked_scalar
|
|
15
|
+
from seampact.codegen.runtime.wire_codec import WireCodec
|
|
16
|
+
from seampact.hooks import RequirementKind
|
|
17
|
+
|
|
18
|
+
_DECLARATIONS: Final = {RequirementKind.RIGHT: "rights", RequirementKind.RULE: "rules"}
|
|
19
|
+
_OBJECT_SCHEMA: Final = "object"
|
|
20
|
+
_UNKNOWN_KEYS_RULE: Final = "unknownKeys"
|
|
21
|
+
_REGENERATE: Final = "regenerate the api."
|
|
22
|
+
# An array's item counts, checked as lengths: each length rule names its item rule.
|
|
23
|
+
_ITEM_RULES: Final = {"minLength": "minItems", "maxLength": "maxItems"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class _ValidatingModel(Protocol):
|
|
28
|
+
"""A generated Pydantic model, read without importing Pydantic, which the DRF target does not install."""
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def model_validate(cls, obj: object, /, *, strict: bool | None = None) -> object: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def requirement_params(contract: ApiContract, kind: RequirementKind, name: str, use_site: IrMap) -> object | None:
|
|
35
|
+
"""`use_site` is the requirement or the field right of the IR. Raises `BoundaryFailureError` for params the right or
|
|
36
|
+
rule does not declare, a params schema the contract has no generated class for, or values its class refuses."""
|
|
37
|
+
schema_id = ir_string(contract.declaration(_DECLARATIONS[kind], name), "params")
|
|
38
|
+
values = copy.deepcopy(dict(ir_map(use_site, "params")))
|
|
39
|
+
|
|
40
|
+
if schema_id is None and values:
|
|
41
|
+
raise BoundaryFailureError(
|
|
42
|
+
f"seampact: a use of '{name}' in the api '{contract.api}' gives params '{name}' does not declare; "
|
|
43
|
+
f"{_REGENERATE}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if schema_id is None:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
model = contract.params_models.get(schema_id)
|
|
50
|
+
|
|
51
|
+
if model is None:
|
|
52
|
+
raise BoundaryFailureError(
|
|
53
|
+
f"seampact: the api '{contract.api}' has no generated class for the params '{schema_id}' of '{name}'; "
|
|
54
|
+
f"{_REGENERATE}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
return _params_instance(contract, name, schema_id, model, values)
|
|
59
|
+
except (ValueError, TypeError) as refused:
|
|
60
|
+
raise BoundaryFailureError(
|
|
61
|
+
f"seampact: the params of '{name}' in the api '{contract.api}' do not match '{schema_id}' ({refused}); "
|
|
62
|
+
f"{_REGENERATE}"
|
|
63
|
+
) from refused
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _params_instance(
|
|
67
|
+
contract: ApiContract, name: str, schema_id: str, model: type, values: dict[str, object]
|
|
68
|
+
) -> object:
|
|
69
|
+
if dataclasses.is_dataclass(model):
|
|
70
|
+
return built_dataclass(model, _ParamsCheck(contract).object_tree(schema_id, values, ""))
|
|
71
|
+
|
|
72
|
+
if not isinstance(model, _ValidatingModel):
|
|
73
|
+
raise BoundaryFailureError(
|
|
74
|
+
f"seampact: the params class '{model.__qualname__}' of '{name}' is neither a Pydantic model nor a "
|
|
75
|
+
f"dataclass; {_REGENERATE}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
tree = WireCodec(contract).to_python({"kind": "ref", "schema": schema_id}, values)
|
|
79
|
+
|
|
80
|
+
if tree.unknown_keys:
|
|
81
|
+
raise ValueRuleError(_UNKNOWN_KEYS_RULE, f"the members {', '.join(tree.unknown_keys)} are not declared")
|
|
82
|
+
|
|
83
|
+
return model.model_validate(tree.value, strict=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _ParamsCheck:
|
|
87
|
+
"""The params values checked against their schema and renamed to Python attributes, scalars converted; each
|
|
88
|
+
refusal raises `ValueRuleError` naming its pointer."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, contract: ApiContract) -> None:
|
|
91
|
+
self._contract = contract
|
|
92
|
+
|
|
93
|
+
def object_tree(self, schema_id: str, value: object, pointer: str) -> dict[str, object]:
|
|
94
|
+
schema = self._contract.declaration("schemas", schema_id)
|
|
95
|
+
|
|
96
|
+
if ir_string(schema, "kind") != _OBJECT_SCHEMA:
|
|
97
|
+
raise ValueRuleError("kind", f"'{schema_id}' at '{pointer}' is not an object schema, which params hold")
|
|
98
|
+
|
|
99
|
+
if not isinstance(value, dict):
|
|
100
|
+
raise ValueRuleError(TYPE_RULE, f"'{pointer}' is not an object")
|
|
101
|
+
|
|
102
|
+
attributes = self._contract.members.get(schema_id, {})
|
|
103
|
+
properties = {ir_string(member, "name") or "": member for member in ir_maps(schema, "properties")}
|
|
104
|
+
unknown = [f"{pointer}/{key}" for key in value if key not in properties or key not in attributes]
|
|
105
|
+
|
|
106
|
+
if unknown:
|
|
107
|
+
raise ValueRuleError(_UNKNOWN_KEYS_RULE, f"the members {', '.join(unknown)} are not declared")
|
|
108
|
+
|
|
109
|
+
renamed: dict[str, object] = {}
|
|
110
|
+
|
|
111
|
+
for member_name, member in properties.items():
|
|
112
|
+
member_pointer = f"{pointer}/{member_name}"
|
|
113
|
+
|
|
114
|
+
if member_name not in value:
|
|
115
|
+
if not ir_true(member, "optional"):
|
|
116
|
+
raise ValueRuleError("required", f"'{member_pointer}' is missing")
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
renamed[attributes[member_name]] = self._member(member, value[member_name], member_pointer)
|
|
120
|
+
|
|
121
|
+
return renamed
|
|
122
|
+
|
|
123
|
+
def _member(self, member: IrMap, value: object, pointer: str) -> object:
|
|
124
|
+
if value is None and not ir_true(member, "nullable"):
|
|
125
|
+
raise ValueRuleError(TYPE_RULE, f"'{pointer}' is null")
|
|
126
|
+
|
|
127
|
+
return None if value is None else self._typed(ir_map(member, "type"), value, pointer)
|
|
128
|
+
|
|
129
|
+
def _typed(self, type_use: IrMap, value: object, pointer: str) -> object:
|
|
130
|
+
kind = ir_string(type_use, "kind")
|
|
131
|
+
|
|
132
|
+
if kind == "ref":
|
|
133
|
+
return self.object_tree(ir_string(type_use, "schema") or "", value, pointer)
|
|
134
|
+
|
|
135
|
+
if kind == "array":
|
|
136
|
+
return self._array(type_use, value, pointer)
|
|
137
|
+
|
|
138
|
+
if kind == "enum":
|
|
139
|
+
return self._enum_value(type_use, value, pointer)
|
|
140
|
+
|
|
141
|
+
if kind == "opaque":
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
return checked_scalar(type_use, value)
|
|
146
|
+
except ValueRuleError as refused:
|
|
147
|
+
raise ValueRuleError(refused.rule, f"'{pointer}' breaks '{refused.rule}'") from refused
|
|
148
|
+
|
|
149
|
+
def _array(self, type_use: IrMap, value: object, pointer: str) -> list[object]:
|
|
150
|
+
if not isinstance(value, list):
|
|
151
|
+
raise ValueRuleError(TYPE_RULE, f"'{pointer}' is not an array")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
check_length(value, ir_int(type_use, "minItems"), ir_int(type_use, "maxItems"))
|
|
155
|
+
except ValueRuleError as refused:
|
|
156
|
+
rule = _ITEM_RULES[refused.rule]
|
|
157
|
+
raise ValueRuleError(rule, f"'{pointer}' breaks '{rule}'") from refused
|
|
158
|
+
|
|
159
|
+
items = ir_map(type_use, "items")
|
|
160
|
+
|
|
161
|
+
return [self._typed(items, item, f"{pointer}/{index}") for index, item in enumerate(value)]
|
|
162
|
+
|
|
163
|
+
def _enum_value(self, type_use: IrMap, value: object, pointer: str) -> str:
|
|
164
|
+
enum = self._contract.declaration("enums", ir_string(type_use, "enum") or "")
|
|
165
|
+
allowed = ir_strings(ir_map(type_use, "narrowedTo"), "values") or ir_strings(enum, "values")
|
|
166
|
+
|
|
167
|
+
if not isinstance(value, str) or value not in allowed:
|
|
168
|
+
raise ValueRuleError("enum", f"'{pointer}' is not one of {', '.join(allowed)}")
|
|
169
|
+
|
|
170
|
+
return value
|