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,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