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.
Files changed (76) hide show
  1. seampact/codegen/__init__.py +2 -0
  2. seampact/codegen/boot_check_hooks.py +38 -0
  3. seampact/codegen/contract_module.py +146 -0
  4. seampact/codegen/credentials_classes.py +99 -0
  5. seampact/codegen/handler_forms.py +132 -0
  6. seampact/codegen/input_schemas.py +130 -0
  7. seampact/codegen/kept_forms.py +522 -0
  8. seampact/codegen/matrix_row.py +23 -0
  9. seampact/codegen/names.py +146 -0
  10. seampact/codegen/native_docs_file.py +169 -0
  11. seampact/codegen/operation_types.py +425 -0
  12. seampact/codegen/placement_lines.py +79 -0
  13. seampact/codegen/printer.py +196 -0
  14. seampact/codegen/py.typed +0 -0
  15. seampact/codegen/pydantic_forms.py +53 -0
  16. seampact/codegen/pydantic_layout.py +178 -0
  17. seampact/codegen/pydantic_models.py +405 -0
  18. seampact/codegen/redacted_members.py +47 -0
  19. seampact/codegen/refusals.py +84 -0
  20. seampact/codegen/request_deadline.py +68 -0
  21. seampact/codegen/response_header_forms.py +73 -0
  22. seampact/codegen/restricted_sort_keys.py +18 -0
  23. seampact/codegen/route_helpers.py +109 -0
  24. seampact/codegen/routes.py +139 -0
  25. seampact/codegen/runtime/__init__.py +7 -0
  26. seampact/codegen/runtime/access_kinds.py +24 -0
  27. seampact/codegen/runtime/access_steps.py +520 -0
  28. seampact/codegen/runtime/body_reader.py +64 -0
  29. seampact/codegen/runtime/cache_headers.py +89 -0
  30. seampact/codegen/runtime/contract.py +232 -0
  31. seampact/codegen/runtime/cors_preflight.py +20 -0
  32. seampact/codegen/runtime/deadline.py +72 -0
  33. seampact/codegen/runtime/digests.py +98 -0
  34. seampact/codegen/runtime/failures.py +33 -0
  35. seampact/codegen/runtime/field_rights.py +193 -0
  36. seampact/codegen/runtime/framework_routes.py +36 -0
  37. seampact/codegen/runtime/framework_versions.py +91 -0
  38. seampact/codegen/runtime/handler_call.py +31 -0
  39. seampact/codegen/runtime/handler_reply.py +148 -0
  40. seampact/codegen/runtime/hinted_values.py +48 -0
  41. seampact/codegen/runtime/hook_check.py +77 -0
  42. seampact/codegen/runtime/idempotency.py +279 -0
  43. seampact/codegen/runtime/input_decoding.py +226 -0
  44. seampact/codegen/runtime/ir_view.py +47 -0
  45. seampact/codegen/runtime/native_docs.py +130 -0
  46. seampact/codegen/runtime/page_facts.py +33 -0
  47. seampact/codegen/runtime/page_reply.py +396 -0
  48. seampact/codegen/runtime/pattern.py +69 -0
  49. seampact/codegen/runtime/principal_members.py +41 -0
  50. seampact/codegen/runtime/public_origin.py +102 -0
  51. seampact/codegen/runtime/pydantic/__init__.py +165 -0
  52. seampact/codegen/runtime/pydantic/checked_input.py +52 -0
  53. seampact/codegen/runtime/pydantic/contract_input.py +183 -0
  54. seampact/codegen/runtime/pydantic/issues.py +142 -0
  55. seampact/codegen/runtime/raw_target.py +103 -0
  56. seampact/codegen/runtime/replies.py +144 -0
  57. seampact/codegen/runtime/reply_verdict.py +215 -0
  58. seampact/codegen/runtime/requirement_params.py +170 -0
  59. seampact/codegen/runtime/response_header_entries.py +226 -0
  60. seampact/codegen/runtime/response_headers.py +114 -0
  61. seampact/codegen/runtime/route_match.py +88 -0
  62. seampact/codegen/runtime/run_settings.py +35 -0
  63. seampact/codegen/runtime/signature_header.py +92 -0
  64. seampact/codegen/runtime/sync_driver.py +35 -0
  65. seampact/codegen/runtime/templates.py +132 -0
  66. seampact/codegen/runtime/uuid_text.py +7 -0
  67. seampact/codegen/runtime/value_rules.py +218 -0
  68. seampact/codegen/runtime/version.py +59 -0
  69. seampact/codegen/runtime/version_token.py +108 -0
  70. seampact/codegen/runtime/wire_codec.py +229 -0
  71. seampact/codegen/runtime/wire_values.py +260 -0
  72. seampact/codegen/source_lines.py +35 -0
  73. seampact_codegen-1.0.0.dist-info/METADATA +18 -0
  74. seampact_codegen-1.0.0.dist-info/RECORD +76 -0
  75. seampact_codegen-1.0.0.dist-info/WHEEL +4 -0
  76. 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