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,33 @@
|
|
|
1
|
+
"""The fact table of a generated page class: the paging style's fact name of each attribute, written by the generator
|
|
2
|
+
so the runtime never rebuilds a fact name from an attribute name."""
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from seampact.codegen.runtime.failures import BoundaryFailureError
|
|
8
|
+
|
|
9
|
+
# The class attribute of a generated page that holds its table, attribute name to fact name.
|
|
10
|
+
PAGE_FACTS: Final = "_FACTS"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def page_facts(operation_id: str, page: object, attributes: Mapping[str, object]) -> dict[str, object]:
|
|
14
|
+
"""The page's attribute values by fact name, read through its class's fact table.
|
|
15
|
+
|
|
16
|
+
Raises `BoundaryFailureError` when the class has no table or an attribute is not in it."""
|
|
17
|
+
table = getattr(type(page), PAGE_FACTS, None)
|
|
18
|
+
|
|
19
|
+
if not isinstance(table, Mapping):
|
|
20
|
+
raise BoundaryFailureError(
|
|
21
|
+
f"seampact: the page of operation '{operation_id}' is a {type(page).__qualname__} without the fact table "
|
|
22
|
+
f"'{PAGE_FACTS}' of a generated page class; return the generated page."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
unknown = sorted(set(attributes) - set(table))
|
|
26
|
+
|
|
27
|
+
if unknown:
|
|
28
|
+
raise BoundaryFailureError(
|
|
29
|
+
f"seampact: the page of operation '{operation_id}' has the attributes {unknown}, which its fact table "
|
|
30
|
+
"does not name; generate the page class again."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return {str(table[attribute]): value for attribute, value in attributes.items()}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""The page a paged list answers (ADR 0002 section 14): the limit the handler receives, then the body, headers and
|
|
2
|
+
`Link` relations the paging style writes from the handler's page facts and the request's position. Links are
|
|
3
|
+
query-only references under relative URLs (`api.wire.urls: relative`), and start with the public origin and the
|
|
4
|
+
request path under absolute ones."""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Final, TypeGuard
|
|
9
|
+
|
|
10
|
+
from seampact.codegen.runtime.contract import QUERY_PART, ApiContract
|
|
11
|
+
from seampact.codegen.runtime.failures import BoundaryFailureError
|
|
12
|
+
from seampact.codegen.runtime.input_decoding import Issue
|
|
13
|
+
from seampact.codegen.runtime.ir_view import IrMap, ir_int, ir_map, ir_maps, ir_string, ir_strings
|
|
14
|
+
from seampact.codegen.runtime.public_origin import writes_absolute_urls
|
|
15
|
+
from seampact.codegen.runtime.templates import render
|
|
16
|
+
from seampact.codegen.runtime.wire_values import component_encoded, kept_query_pairs, path_link_safe, scalar_text
|
|
17
|
+
|
|
18
|
+
LINK: Final = "link"
|
|
19
|
+
|
|
20
|
+
_LIMIT_ROLE: Final = "limit"
|
|
21
|
+
_BEFORE_ROLE: Final = "before"
|
|
22
|
+
_AFTER_ROLE: Final = "after"
|
|
23
|
+
_CLAMP: Final = "clamp"
|
|
24
|
+
_EXCLUSIVE_CURSORS_RULE: Final = "exclusiveCursors"
|
|
25
|
+
_NULL_ABSENT: Final = "null"
|
|
26
|
+
_PAGE: Final = "page"
|
|
27
|
+
_OFFSET: Final = "offset"
|
|
28
|
+
_SINCE: Final = "since"
|
|
29
|
+
_CURSOR: Final = "cursor"
|
|
30
|
+
_MORE: Final = "more"
|
|
31
|
+
_TOTAL: Final = "total"
|
|
32
|
+
_DEFAULT_PAGE_BASE: Final = 1
|
|
33
|
+
_FIRST_OFFSET: Final = 0
|
|
34
|
+
# The largest position the contract's int64 page and offset parameters carry.
|
|
35
|
+
_INT64_MAX: Final = 2**63 - 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class _PageLinks:
|
|
40
|
+
"""The query the client sent and what each link starts with: nothing under relative URLs, the public origin and
|
|
41
|
+
the request path under absolute ones."""
|
|
42
|
+
|
|
43
|
+
raw_query: str
|
|
44
|
+
url_base: str
|
|
45
|
+
|
|
46
|
+
def with_member(self, name: str, value: str) -> str:
|
|
47
|
+
return _page_url(self.url_base, _with_query_member(self.raw_query, name, value))
|
|
48
|
+
|
|
49
|
+
def without_members(self, names: Sequence[str]) -> str:
|
|
50
|
+
return _page_url(self.url_base, _without_query_members(self.raw_query, names))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class PageParts:
|
|
55
|
+
"""The page body (`{}` when the template writes none) and the headers its style writes, names in lower case."""
|
|
56
|
+
|
|
57
|
+
body: object
|
|
58
|
+
headers: tuple[tuple[str, str], ...]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def filled_page_query(contract: ApiContract, operation_id: str, query: Mapping[str, object]) -> dict[str, object]:
|
|
62
|
+
"""The query the handler receives: the route's default limit when the client sent none, and the route's
|
|
63
|
+
`maxLimit` for a larger one under `overLimit: clamp` (a larger one under `reject` was refused by the input
|
|
64
|
+
step)."""
|
|
65
|
+
paging = ir_map(contract.operation(operation_id), "paging")
|
|
66
|
+
limit_param = _role_name(_style(contract, operation_id), _LIMIT_ROLE)
|
|
67
|
+
filled = dict(query)
|
|
68
|
+
|
|
69
|
+
if not paging or limit_param is None:
|
|
70
|
+
return filled
|
|
71
|
+
|
|
72
|
+
limit = _effective_limit(paging, query.get(limit_param))
|
|
73
|
+
|
|
74
|
+
if limit is not None:
|
|
75
|
+
filled[limit_param] = limit
|
|
76
|
+
|
|
77
|
+
return filled
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cursor_issues(contract: ApiContract, operation_id: str, query: Mapping[str, object]) -> list[Issue]:
|
|
81
|
+
"""A style whose cursors travel in an `after` and a `before` parameter takes one of them: both sent is one
|
|
82
|
+
`exclusiveCursors` issue at the `before` parameter, reported with the other input issues."""
|
|
83
|
+
style = _style(contract, operation_id)
|
|
84
|
+
after_param = _role_name(style, _AFTER_ROLE)
|
|
85
|
+
before_param = _role_name(style, _BEFORE_ROLE)
|
|
86
|
+
|
|
87
|
+
if after_param is None or before_param is None:
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
if after_param not in query or before_param not in query:
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
return [Issue(QUERY_PART, f"/{before_param}", _EXCLUSIVE_CURSORS_RULE)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def page_parts(
|
|
97
|
+
contract: ApiContract,
|
|
98
|
+
operation_id: str,
|
|
99
|
+
query: Mapping[str, object],
|
|
100
|
+
raw_query: str,
|
|
101
|
+
items: Sequence[object],
|
|
102
|
+
facts: Mapping[str, object],
|
|
103
|
+
status: int,
|
|
104
|
+
request_path: str,
|
|
105
|
+
*,
|
|
106
|
+
public_origin: str | None = None,
|
|
107
|
+
) -> PageParts:
|
|
108
|
+
"""The page body and headers from the handler's page facts by name (`items` already encoded and projected), the
|
|
109
|
+
decoded `query` and the raw one the links keep. `request_path` is the path as the client sent it, still
|
|
110
|
+
percent-encoded; `public_origin` is `configured_origin`'s answer, None under relative URLs.
|
|
111
|
+
|
|
112
|
+
Raises `BoundaryFailureError` for absolute URLs without a public origin, an origin under relative URLs, or a
|
|
113
|
+
numbered style without a limit."""
|
|
114
|
+
style = _style(contract, operation_id)
|
|
115
|
+
write_nulls = ir_string(style, "absent") == _NULL_ABSENT
|
|
116
|
+
slots: dict[str, object] = {
|
|
117
|
+
name: facts[name] for name in ir_strings(style, "handlerResult") if facts.get(name) is not None
|
|
118
|
+
}
|
|
119
|
+
limit = _limit_of(contract, operation_id, style, query)
|
|
120
|
+
|
|
121
|
+
if limit is not None:
|
|
122
|
+
slots[_LIMIT_ROLE] = limit
|
|
123
|
+
total = facts.get(_TOTAL)
|
|
124
|
+
|
|
125
|
+
if _is_int(total) and limit > 0:
|
|
126
|
+
slots["totalPages"] = -(-total // limit)
|
|
127
|
+
|
|
128
|
+
links = _PageLinks(raw_query, _url_base(contract, public_origin, request_path))
|
|
129
|
+
slots["self"] = links.without_members([])
|
|
130
|
+
position = ir_string(style, "position")
|
|
131
|
+
|
|
132
|
+
if position in (_PAGE, _OFFSET):
|
|
133
|
+
_add_numbered_slots(operation_id, style, query, links, facts, len(items), limit, slots)
|
|
134
|
+
else:
|
|
135
|
+
_add_token_slots(style, links, facts, position, slots)
|
|
136
|
+
|
|
137
|
+
slots["items"] = list(items)
|
|
138
|
+
slots["status"] = status
|
|
139
|
+
slots["path"] = request_path
|
|
140
|
+
meta = _rendered_meta(style, slots, write_nulls)
|
|
141
|
+
|
|
142
|
+
if meta is not None:
|
|
143
|
+
slots["meta"] = meta
|
|
144
|
+
|
|
145
|
+
body = render(_page_body(contract, operation_id, style), slots, write_nulls)
|
|
146
|
+
|
|
147
|
+
return PageParts(body.value if body.present else {}, _headers(operation_id, style, slots))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _style(contract: ApiContract, operation_id: str) -> IrMap:
|
|
151
|
+
style_id = ir_string(ir_map(contract.operation(operation_id), "paging"), "style") or ""
|
|
152
|
+
|
|
153
|
+
return contract.declaration("pagings", style_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _page_body(contract: ApiContract, operation_id: str, style: IrMap) -> object:
|
|
157
|
+
"""The style's own body, or the format's page body, whose `$meta` the style's meta joins."""
|
|
158
|
+
body = ir_map(style, "response").get("body")
|
|
159
|
+
|
|
160
|
+
if body is not None:
|
|
161
|
+
return body
|
|
162
|
+
|
|
163
|
+
return ir_map(ir_map(contract.format(operation_id), "success"), _PAGE).get("body")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _rendered_meta(style: IrMap, slots: Mapping[str, object], write_nulls: bool) -> dict[str, object] | None:
|
|
167
|
+
"""The style's meta members, joined into the format's `$meta`; none leaves `$meta` without a value."""
|
|
168
|
+
meta: dict[str, object] = {}
|
|
169
|
+
|
|
170
|
+
for member in ir_maps(ir_map(style, "response"), "meta"):
|
|
171
|
+
value = render(member.get("value"), slots, write_nulls)
|
|
172
|
+
name = ir_string(member, "name") or ""
|
|
173
|
+
|
|
174
|
+
if value.present:
|
|
175
|
+
meta[name] = value.value
|
|
176
|
+
elif write_nulls:
|
|
177
|
+
meta[name] = None
|
|
178
|
+
|
|
179
|
+
return meta or None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _headers(operation_id: str, style: IrMap, slots: Mapping[str, object]) -> tuple[tuple[str, str], ...]:
|
|
183
|
+
"""The style's headers, each its value's text; an absent or null value writes none. Raises
|
|
184
|
+
`BoundaryFailureError` for a value with no text (an object, an array), which the generator refuses in a
|
|
185
|
+
template."""
|
|
186
|
+
response = ir_map(style, "response")
|
|
187
|
+
headers: list[tuple[str, str]] = []
|
|
188
|
+
|
|
189
|
+
for header in ir_maps(response, "headers"):
|
|
190
|
+
value = render(header.get("value"), slots, write_nulls=False)
|
|
191
|
+
name = ir_string(header, "name") or ""
|
|
192
|
+
|
|
193
|
+
if not value.present or value.value is None:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
text = scalar_text(value.value)
|
|
197
|
+
|
|
198
|
+
if text is None:
|
|
199
|
+
raise BoundaryFailureError(
|
|
200
|
+
f"seampact: the page header '{name}' of '{operation_id}' renders a {type(value.value).__qualname__}, "
|
|
201
|
+
"not a text value; write it from a scalar slot."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
headers.append((name.lower(), text))
|
|
205
|
+
|
|
206
|
+
relations = [
|
|
207
|
+
f'<{url}>; rel="{ir_string(link, "rel") or ""}"'
|
|
208
|
+
for link in ir_maps(response, "links")
|
|
209
|
+
if isinstance(url := slots.get(ir_string(link, "role") or ""), str)
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
if relations:
|
|
213
|
+
headers.append((LINK, ", ".join(relations)))
|
|
214
|
+
|
|
215
|
+
return tuple(headers)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _add_numbered_slots(
|
|
219
|
+
operation_id: str,
|
|
220
|
+
style: IrMap,
|
|
221
|
+
query: Mapping[str, object],
|
|
222
|
+
links: _PageLinks,
|
|
223
|
+
facts: Mapping[str, object],
|
|
224
|
+
item_count: int,
|
|
225
|
+
limit: int | None,
|
|
226
|
+
slots: dict[str, object],
|
|
227
|
+
) -> None:
|
|
228
|
+
"""A page or offset position: the boundary counts the page, its records and the URLs from the request."""
|
|
229
|
+
if limit is None or limit <= 0:
|
|
230
|
+
raise BoundaryFailureError(f"seampact: the list '{operation_id}' has no limit; regenerate the api.")
|
|
231
|
+
|
|
232
|
+
position = ir_string(style, "position")
|
|
233
|
+
is_page = position == _PAGE
|
|
234
|
+
position_role = _position_role(style)
|
|
235
|
+
position_param = ir_string(position_role, "name") or ""
|
|
236
|
+
base = ir_int(position_role, "base")
|
|
237
|
+
base = _DEFAULT_PAGE_BASE if base is None else base
|
|
238
|
+
sent = query.get(position_param)
|
|
239
|
+
|
|
240
|
+
if is_page:
|
|
241
|
+
page_number = sent if _is_int(sent) else base
|
|
242
|
+
slots[_PAGE] = page_number
|
|
243
|
+
page_index = page_number - base
|
|
244
|
+
offset = min(page_index * limit, _INT64_MAX)
|
|
245
|
+
else:
|
|
246
|
+
offset = sent if _is_int(sent) else _FIRST_OFFSET
|
|
247
|
+
page_index = offset // limit
|
|
248
|
+
|
|
249
|
+
slots[_OFFSET] = offset
|
|
250
|
+
total = facts.get(_TOTAL)
|
|
251
|
+
# A position past the last int64 offset has no next page and no record numbers, as on every target.
|
|
252
|
+
next_fits = offset <= _INT64_MAX - limit
|
|
253
|
+
has_next = next_fits and (
|
|
254
|
+
facts.get(_MORE) is True
|
|
255
|
+
if _MORE in ir_strings(style, "handlerResult")
|
|
256
|
+
else _is_int(total) and offset + limit < total
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if item_count > 0 and offset <= _INT64_MAX - item_count:
|
|
260
|
+
slots["from"] = offset + 1
|
|
261
|
+
slots["to"] = offset + item_count
|
|
262
|
+
|
|
263
|
+
slots["first"] = links.with_member(position_param, _position_at(position, base, 0, limit))
|
|
264
|
+
|
|
265
|
+
if offset > _FIRST_OFFSET:
|
|
266
|
+
previous = str(base + page_index - 1) if is_page else str(max(offset - limit, _FIRST_OFFSET))
|
|
267
|
+
slots["previous"] = links.with_member(position_param, previous)
|
|
268
|
+
|
|
269
|
+
if has_next:
|
|
270
|
+
# An offset the client chose need not fall on a page start: the next page starts one limit further.
|
|
271
|
+
following = _position_at(_PAGE, base, page_index + 1, limit) if is_page else str(offset + limit)
|
|
272
|
+
slots["next"] = links.with_member(position_param, following)
|
|
273
|
+
|
|
274
|
+
total_pages = slots.get("totalPages")
|
|
275
|
+
|
|
276
|
+
if _is_int(total_pages):
|
|
277
|
+
last_index = max(total_pages - 1, 0)
|
|
278
|
+
last = _position_at(position, base, last_index, limit)
|
|
279
|
+
slots["last"] = links.with_member(position_param, last)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _add_token_slots(
|
|
283
|
+
style: IrMap, links: _PageLinks, facts: Mapping[str, object], position: str | None, slots: dict[str, object]
|
|
284
|
+
) -> None:
|
|
285
|
+
"""A cursor or since position: the URLs carry the tokens the handler returned."""
|
|
286
|
+
position_param = ir_string(_position_role(style), "name") or ""
|
|
287
|
+
before_param = _role_name(style, _BEFORE_ROLE)
|
|
288
|
+
removed = [position_param] if before_param is None else [position_param, before_param]
|
|
289
|
+
without_position = _PageLinks(_without_query_members(links.raw_query, removed), links.url_base)
|
|
290
|
+
slots["first"] = without_position.without_members([])
|
|
291
|
+
tokens = (
|
|
292
|
+
[("next", position_param, facts.get("nextSince"))]
|
|
293
|
+
if position == _SINCE
|
|
294
|
+
else [
|
|
295
|
+
("next", position_param, facts.get("nextCursor")),
|
|
296
|
+
("previous", before_param or position_param, facts.get("previousCursor")),
|
|
297
|
+
]
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
for role, param, token in tokens:
|
|
301
|
+
if isinstance(token, str):
|
|
302
|
+
slots[role] = without_position.with_member(param, token)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _position_at(position: str | None, base: int, index: int, limit: int) -> str:
|
|
306
|
+
"""The page number, or the offset, of the page at `index`."""
|
|
307
|
+
return str(base + index if position == _PAGE else index * limit)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _position_role(style: IrMap) -> IrMap:
|
|
311
|
+
"""A cursor style may carry its position in `after` and `before` instead of `cursor`."""
|
|
312
|
+
position = ir_string(style, "position") or ""
|
|
313
|
+
role = _role_of(style, position)
|
|
314
|
+
|
|
315
|
+
if role is None and position == _CURSOR:
|
|
316
|
+
role = _role_of(style, _AFTER_ROLE)
|
|
317
|
+
|
|
318
|
+
if role is None:
|
|
319
|
+
raise BoundaryFailureError(
|
|
320
|
+
f"seampact: the paging style '{ir_string(style, 'id') or ''}' has no position role; regenerate the api."
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return role
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _role_name(style: IrMap, role: str) -> str | None:
|
|
327
|
+
return ir_string(_role_of(style, role), "name")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _role_of(style: IrMap, role: str) -> IrMap | None:
|
|
331
|
+
return next((declared for declared in ir_maps(style, "request") if ir_string(declared, "role") == role), None)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _limit_of(contract: ApiContract, operation_id: str, style: IrMap, query: Mapping[str, object]) -> int | None:
|
|
335
|
+
limit_param = _role_name(style, _LIMIT_ROLE)
|
|
336
|
+
paging = ir_map(contract.operation(operation_id), "paging")
|
|
337
|
+
|
|
338
|
+
if limit_param is None:
|
|
339
|
+
return ir_int(paging, "defaultLimit")
|
|
340
|
+
|
|
341
|
+
return _effective_limit(paging, query.get(limit_param))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _effective_limit(paging: IrMap, sent: object) -> int | None:
|
|
345
|
+
"""The default for none sent, `maxLimit` for a larger one under `clamp`."""
|
|
346
|
+
if not _is_int(sent):
|
|
347
|
+
return ir_int(paging, "defaultLimit")
|
|
348
|
+
|
|
349
|
+
max_limit = ir_int(paging, "maxLimit")
|
|
350
|
+
|
|
351
|
+
if ir_string(paging, "overLimit") == _CLAMP and max_limit is not None and sent > max_limit:
|
|
352
|
+
return max_limit
|
|
353
|
+
|
|
354
|
+
return sent
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _without_query_members(raw_query: str, names: Sequence[str]) -> str:
|
|
358
|
+
return "&".join(kept_query_pairs(raw_query, names))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _with_query_member(raw_query: str, name: str, value: str) -> str:
|
|
362
|
+
kept = _without_query_members(raw_query, [name])
|
|
363
|
+
member = f"{component_encoded(name)}={component_encoded(value)}"
|
|
364
|
+
|
|
365
|
+
return member if kept == "" else f"{kept}&{member}"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _url_base(contract: ApiContract, public_origin: str | None, request_path: str) -> str:
|
|
369
|
+
if not writes_absolute_urls(contract):
|
|
370
|
+
if public_origin is not None:
|
|
371
|
+
raise BoundaryFailureError(
|
|
372
|
+
f"seampact: the api '{contract.api}' writes relative URLs and its page links were given a public "
|
|
373
|
+
"origin; remove public_origin, or set 'api.wire.urls: absolute'."
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return ""
|
|
377
|
+
|
|
378
|
+
if public_origin is None:
|
|
379
|
+
raise BoundaryFailureError(
|
|
380
|
+
f"seampact: the api '{contract.api}' writes absolute URLs ('api.wire.urls: absolute') and its page links "
|
|
381
|
+
"have no valid public origin; set public_origin to the origin clients call, such as "
|
|
382
|
+
"https://api.example.com."
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return f"{public_origin}{path_link_safe(request_path)}"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _page_url(url_base: str, query: str) -> str:
|
|
389
|
+
"""Under relative URLs a query-only reference (RFC 3986): it resolves against the URL the client called, so it
|
|
390
|
+
keeps a path prefix a reverse proxy stripped. An empty query is `?`, since an empty reference would keep the
|
|
391
|
+
called query. Under absolute URLs the base is the public origin and the request path."""
|
|
392
|
+
return f"{url_base}?{query}"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _is_int(value: object) -> TypeGuard[int]:
|
|
396
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The framework-free pattern check: a contract pattern's normalised form (ADR 0003 section 9) translated for
|
|
2
|
+
Python's `re` and searched anywhere in the text, as JSON Schema reads `pattern`. The normalised form already spells
|
|
3
|
+
`.` and the shorthands as explicit classes, so the translation only maps `^` and `$` to the start and the end of the
|
|
4
|
+
string (`\\A`, `\\Z`, never before a final newline) and an astral escape `\\u{...}` to `\\U........`."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from functools import cache
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
_ASTRAL_ESCAPE: Final = re.compile(r"u\{([0-9A-F]+)\}")
|
|
12
|
+
_ANCHORS: Final = {"^": r"\A", "$": r"\Z"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ContractPattern:
|
|
17
|
+
normalized: str
|
|
18
|
+
_compiled: re.Pattern[str] = field(init=False, repr=False, compare=False)
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
object.__setattr__(self, "_compiled", re.compile(python_regex(self.normalized)))
|
|
22
|
+
|
|
23
|
+
def matches(self, text: str) -> bool:
|
|
24
|
+
return self._compiled.search(text) is not None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cache
|
|
28
|
+
def contract_pattern(normalized: str) -> ContractPattern:
|
|
29
|
+
"""The compiled check of a normalised pattern, compiled once per process.
|
|
30
|
+
|
|
31
|
+
Raises `re.error` for a text that is not a normalised pattern."""
|
|
32
|
+
return ContractPattern(normalized)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def python_regex(normalized: str) -> str:
|
|
36
|
+
"""The `re` source of a normalised pattern."""
|
|
37
|
+
translated: list[str] = []
|
|
38
|
+
in_class = False
|
|
39
|
+
index = 0
|
|
40
|
+
|
|
41
|
+
while index < len(normalized):
|
|
42
|
+
char = normalized[index]
|
|
43
|
+
|
|
44
|
+
if char == "\\":
|
|
45
|
+
escape, index = _escape(normalized, index + 1)
|
|
46
|
+
translated.append(escape)
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
if in_class:
|
|
50
|
+
in_class = char != "]"
|
|
51
|
+
elif char == "[":
|
|
52
|
+
in_class = True
|
|
53
|
+
else:
|
|
54
|
+
char = _ANCHORS.get(char, char)
|
|
55
|
+
|
|
56
|
+
translated.append(char)
|
|
57
|
+
index += 1
|
|
58
|
+
|
|
59
|
+
return "".join(translated)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _escape(normalized: str, index: int) -> tuple[str, int]:
|
|
63
|
+
"""The escape starting after the backslash at `index - 1`, and the index after it."""
|
|
64
|
+
astral = _ASTRAL_ESCAPE.match(normalized, index)
|
|
65
|
+
|
|
66
|
+
if astral is not None:
|
|
67
|
+
return f"\\U{int(astral.group(1), 16):08X}", astral.end()
|
|
68
|
+
|
|
69
|
+
return "\\" + normalized[index : index + 1], index + 1
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The members of an authenticated principal the boundary reads (ADR 0023 "Keys"): only those the principal schema
|
|
2
|
+
declares, by wire name, as the wire writes them. The idempotency partition and a principal bucket key come from them,
|
|
3
|
+
so two callers never share a key because their principal has no readable state."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
8
|
+
from seampact.codegen.runtime.failures import BoundaryFailureError
|
|
9
|
+
from seampact.codegen.runtime.ir_view import ir_map, ir_string
|
|
10
|
+
from seampact.codegen.runtime.wire_codec import WireCodec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def principal_members(
|
|
14
|
+
contract: ApiContract, operation_id: str, principal: object | None
|
|
15
|
+
) -> Mapping[str, object] | None:
|
|
16
|
+
"""Each declared member of the operation's principal by wire name, None for an anonymous caller.
|
|
17
|
+
|
|
18
|
+
Raises `BoundaryFailureError` for a principal that is not the generated principal type, or one whose schema
|
|
19
|
+
declares no member, since its callers could not be told apart."""
|
|
20
|
+
if principal is None:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
access = ir_map(contract.operation(operation_id), "access")
|
|
24
|
+
identity = ir_string(access, "identity") or ""
|
|
25
|
+
schema_id = ir_string(ir_map(contract.declaration("identities", identity), "principal"), "schema") or ""
|
|
26
|
+
|
|
27
|
+
if not contract.members.get(schema_id):
|
|
28
|
+
raise BoundaryFailureError(
|
|
29
|
+
f"seampact: the principal of identity '{identity}' declares no member, so its callers cannot be told "
|
|
30
|
+
"apart; declare one in its schema and regenerate the api."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
members = WireCodec(contract).to_wire({"kind": "ref", "schema": schema_id}, principal)
|
|
34
|
+
|
|
35
|
+
if not isinstance(members, Mapping):
|
|
36
|
+
raise BoundaryFailureError(
|
|
37
|
+
f"seampact: the authenticate hook of identity '{identity}' returned a {type(principal).__qualname__}; "
|
|
38
|
+
f"return the generated principal type of '{schema_id}'."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return members
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The origin absolute URLs start with (`api.wire.urls: absolute`): a scheme, a host and an optional port, from the
|
|
2
|
+
app's configuration (FastAPI's `public_origin` argument, Django's `public_origin` setting). The request's `Host` and
|
|
3
|
+
forwarded headers are never read. The boot check's item names the setting and the fix."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
9
|
+
from seampact.codegen.runtime.ir_view import ir_map, ir_string
|
|
10
|
+
|
|
11
|
+
_ABSOLUTE_URLS: Final = "absolute"
|
|
12
|
+
_SCHEMES: Final = ("http", "https")
|
|
13
|
+
_SCHEME_SEPARATOR: Final = "://"
|
|
14
|
+
_USER_INFO_SEPARATOR: Final = "@"
|
|
15
|
+
_AUTHORITY_END: Final = re.compile(r"[/?#]")
|
|
16
|
+
# What Java's `URI` reads as a server authority: a DNS name or IPv4 address, or a bracketed IPv6 address, then an
|
|
17
|
+
# optional port of digits.
|
|
18
|
+
_HOST_AND_PORT: Final = re.compile(
|
|
19
|
+
r"(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*"
|
|
20
|
+
r"|\[[0-9A-Fa-f:.]+\])"
|
|
21
|
+
r"(?::(?P<port>[0-9]{1,5}))?"
|
|
22
|
+
)
|
|
23
|
+
_LOWEST_PORT: Final = 1
|
|
24
|
+
_HIGHEST_PORT: Final = 65535
|
|
25
|
+
_EXAMPLE: Final = "https://api.example.com"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def writes_absolute_urls(contract: ApiContract) -> bool:
|
|
29
|
+
return ir_string(ir_map(ir_map(contract.ir_view, "api"), "wire"), "urls") == _ABSOLUTE_URLS
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def configured_origin(contract: ApiContract, configured: object) -> str | None:
|
|
33
|
+
"""The configured origin under absolute URLs; None under relative ones, or when the boot check refuses it."""
|
|
34
|
+
if not writes_absolute_urls(contract) or not isinstance(configured, str) or _problem_of(configured) is not None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
return configured
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def public_origin_problem(contract: ApiContract, setting: str, configured: object) -> str | None:
|
|
41
|
+
"""The boot check's problem with the origin set in `setting`: one set under relative URLs, or a missing or invalid
|
|
42
|
+
one under absolute URLs."""
|
|
43
|
+
is_set = configured is not None and configured != ""
|
|
44
|
+
|
|
45
|
+
if not writes_absolute_urls(contract):
|
|
46
|
+
if not is_set:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
f"the api '{contract.api}' writes relative URLs ('api.wire.urls: relative') and {setting} is set: remove "
|
|
51
|
+
"it, or set 'api.wire.urls: absolute' to write absolute URLs"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not is_set:
|
|
55
|
+
return (
|
|
56
|
+
f"the api '{contract.api}' writes absolute URLs ('api.wire.urls: absolute') and {setting} is not set: set "
|
|
57
|
+
f"it to the origin clients call, such as {_EXAMPLE}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
problem = _problem_of(configured) if isinstance(configured, str) else "is not a string"
|
|
61
|
+
|
|
62
|
+
if problem is None:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
# The value is shown by its repr, so a line break or a control character never reaches the log line raw.
|
|
66
|
+
shown = repr(configured) if isinstance(configured, str) else type(configured).__qualname__
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
f"{setting} is {shown}, which {problem}: set a scheme (http or https), a host and an optional port, such as "
|
|
70
|
+
f"{_EXAMPLE}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _problem_of(configured: str) -> str | None:
|
|
75
|
+
scheme, separator, rest = configured.partition(_SCHEME_SEPARATOR)
|
|
76
|
+
|
|
77
|
+
if separator == "" or scheme not in _SCHEMES:
|
|
78
|
+
return "has no http or https scheme"
|
|
79
|
+
|
|
80
|
+
end = _AUTHORITY_END.search(rest)
|
|
81
|
+
authority = rest if end is None else rest[: end.start()]
|
|
82
|
+
|
|
83
|
+
if _USER_INFO_SEPARATOR in authority:
|
|
84
|
+
return "carries user info"
|
|
85
|
+
|
|
86
|
+
if authority != rest:
|
|
87
|
+
return "has a path, a query or a fragment (a trailing slash included)"
|
|
88
|
+
|
|
89
|
+
if authority == "":
|
|
90
|
+
return "has no host"
|
|
91
|
+
|
|
92
|
+
host_and_port = _HOST_AND_PORT.fullmatch(authority)
|
|
93
|
+
|
|
94
|
+
if host_and_port is None:
|
|
95
|
+
return "has a host that is not a DNS name, an IPv4 address or a bracketed IPv6 address, or an empty port"
|
|
96
|
+
|
|
97
|
+
port = host_and_port.group("port")
|
|
98
|
+
|
|
99
|
+
if port is not None and not _LOWEST_PORT <= int(port) <= _HIGHEST_PORT:
|
|
100
|
+
return f"has a port outside {_LOWEST_PORT}-{_HIGHEST_PORT}"
|
|
101
|
+
|
|
102
|
+
return None
|