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,38 @@
|
|
|
1
|
+
"""The `artefacts.bootCheck.hooks` value, read the same on every Python target: `error` (default) or `off`; `warn`
|
|
2
|
+
is refused by name until a consumer needs it, any other value as invalid."""
|
|
3
|
+
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
from seampact.codegen.refusals import Delivery, IrPlace, Refusals
|
|
7
|
+
from seampact.ir.config.json_value import JsonValue
|
|
8
|
+
|
|
9
|
+
HOOKS_ERROR: Final = "error"
|
|
10
|
+
HOOKS_OFF: Final = "off"
|
|
11
|
+
_HOOKS_WARN: Final = "warn"
|
|
12
|
+
_NAME: Final = "bootCheck.hooks"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_boot_check_hooks(value: JsonValue, at: IrPlace, refusals: Refusals) -> bool:
|
|
16
|
+
"""Whether a missing hook fails the boot; a refused value reads as the default, `error`."""
|
|
17
|
+
if value == HOOKS_OFF:
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
if value == HOOKS_ERROR:
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
if value == _HOOKS_WARN:
|
|
24
|
+
refusals.waiting(
|
|
25
|
+
f"artefacts.{_NAME}.{_HOOKS_WARN}",
|
|
26
|
+
"'bootCheck.hooks: warn' (start with hooks missing)",
|
|
27
|
+
at,
|
|
28
|
+
Delivery.CONSUMER,
|
|
29
|
+
)
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
refusals.add(
|
|
33
|
+
f"artefacts.{_NAME}.invalid",
|
|
34
|
+
f"The artefact '{_NAME}' is {value!r}, not '{HOOKS_ERROR}', '{HOOKS_OFF}' or '{_HOOKS_WARN}'.",
|
|
35
|
+
f"Set '{_NAME}' to one of the listed values, or remove it for its default.",
|
|
36
|
+
at,
|
|
37
|
+
)
|
|
38
|
+
return True
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The `contract` module of an api's package, the same on every Python target (ADR 0026 "Placement", ADR 0027): the
|
|
2
|
+
`ApiContract` the runtime reads per operation. It holds the IR of the kept operations whole (security and access,
|
|
3
|
+
idempotency, paging, relay, cache, the unknown-key and unknown-query policies, the body's rule proofs) and the
|
|
4
|
+
declarations they read, then the Python attribute of each member's wire name: per object schema, and per operation for
|
|
5
|
+
its path, query and header parameters and its body's rule proofs."""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from seampact.codegen.kept_forms import handler_headers
|
|
11
|
+
from seampact.codegen.names import ApiNames, member_identifier, part_attributes
|
|
12
|
+
from seampact.codegen.printer import INDENT, PythonModule, literal_lines, string_literal
|
|
13
|
+
from seampact.codegen.refusals import IrPlace, Refusals
|
|
14
|
+
from seampact.codegen.runtime.contract import HEADER_PART, PATH_PART, PROOFS_PART, QUERY_PART
|
|
15
|
+
from seampact.codegen.runtime.version import RUNTIME_VERSION
|
|
16
|
+
from seampact.ir.model import ContractIR, Operation, Param
|
|
17
|
+
|
|
18
|
+
# The IR members the runtime reads beside the operations; the others stay out of the module.
|
|
19
|
+
IR_VIEW_MEMBERS: Final = (
|
|
20
|
+
"api",
|
|
21
|
+
"errors",
|
|
22
|
+
"formats",
|
|
23
|
+
"identities",
|
|
24
|
+
"schemes",
|
|
25
|
+
"scopes",
|
|
26
|
+
"rights",
|
|
27
|
+
"rules",
|
|
28
|
+
"buckets",
|
|
29
|
+
"pagings",
|
|
30
|
+
"schemas",
|
|
31
|
+
"enums",
|
|
32
|
+
)
|
|
33
|
+
CONTRACT_NAME: Final = "CONTRACT"
|
|
34
|
+
_SCHEMAS_MODULE: Final = ".schemas"
|
|
35
|
+
_SCHEMAS_ALIAS: Final = "_schemas"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def contract_module(ir: ContractIR, names: ApiNames, mount: str, refusals: Refusals) -> str:
|
|
39
|
+
"""`names` may hold more schemas than `ir` (a target's own input models): only the IR's schemas are written."""
|
|
40
|
+
module = PythonModule(f"The contract of api '{ir.api.id}' as its boundary reads it.")
|
|
41
|
+
typing = module.imported("typing", "_typing")
|
|
42
|
+
runtime = module.imported("seampact.codegen.runtime.contract", "_contract")
|
|
43
|
+
arguments: list[tuple[str, object]] = [
|
|
44
|
+
("api", ir.api.id),
|
|
45
|
+
("generated_by", RUNTIME_VERSION),
|
|
46
|
+
("mount", mount),
|
|
47
|
+
("ir_view", ir_view(ir)),
|
|
48
|
+
("members", {schema_id: names.members[schema_id] for schema_id in ir.schemas if schema_id in names.members}),
|
|
49
|
+
("operation_members", _operation_members(ir, refusals)),
|
|
50
|
+
]
|
|
51
|
+
lines = [f"{CONTRACT_NAME}: {typing}.Final = {runtime}.ApiContract("]
|
|
52
|
+
|
|
53
|
+
for name, value in arguments:
|
|
54
|
+
lines += literal_lines(INDENT, f"{name}=", value, ",")
|
|
55
|
+
|
|
56
|
+
lines += _params_models(module, _params_classes(ir, names, refusals))
|
|
57
|
+
module.add_block([*lines, ")"])
|
|
58
|
+
|
|
59
|
+
return module.text()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ir_view(ir: ContractIR) -> dict[str, object]:
|
|
63
|
+
"""The IR members the runtime reads, as the IR's JSON writes them."""
|
|
64
|
+
document = ir.to_json()
|
|
65
|
+
view: dict[str, object] = {member: document.get(member, {}) for member in IR_VIEW_MEMBERS}
|
|
66
|
+
view["operations"] = document["operations"]
|
|
67
|
+
|
|
68
|
+
return view
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _params_classes(ir: ContractIR, names: ApiNames, refusals: Refusals) -> dict[str, str]:
|
|
72
|
+
"""The generated class of each params schema a right or rule declares, by schema id."""
|
|
73
|
+
declared = [(f"/rights/{name}/params", right.params) for name, right in sorted(ir.rights.items())]
|
|
74
|
+
declared += [(f"/rules/{name}/params", rule.params) for name, rule in sorted(ir.rules.items())]
|
|
75
|
+
classes: dict[str, str] = {}
|
|
76
|
+
|
|
77
|
+
for pointer, schema_id in declared:
|
|
78
|
+
if schema_id is None:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
class_name = names.schema_classes.get(schema_id)
|
|
82
|
+
|
|
83
|
+
if class_name is None:
|
|
84
|
+
refusals.inconsistent(
|
|
85
|
+
"params.class.missing", f"The params '{schema_id}' have no generated class", IrPlace(pointer)
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
classes[schema_id] = class_name
|
|
90
|
+
|
|
91
|
+
return dict(sorted(classes.items()))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _params_models(module: PythonModule, classes: Mapping[str, str]) -> list[str]:
|
|
95
|
+
"""The `params_models=` argument; none when no right or rule declares params."""
|
|
96
|
+
if not classes:
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
schemas = module.imported(_SCHEMAS_MODULE, _SCHEMAS_ALIAS)
|
|
100
|
+
entries = [f"{INDENT * 2}{string_literal(schema_id)}: {schemas}.{name}," for schema_id, name in classes.items()]
|
|
101
|
+
|
|
102
|
+
return [f"{INDENT}params_models={{", *entries, f"{INDENT}}},"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _operation_members(ir: ContractIR, refusals: Refusals) -> dict[str, dict[str, Mapping[str, str]]]:
|
|
106
|
+
members: dict[str, dict[str, Mapping[str, str]]] = {}
|
|
107
|
+
|
|
108
|
+
for index, operation in enumerate(ir.operations):
|
|
109
|
+
at = IrPlace(f"/operations/{index}", operation.id)
|
|
110
|
+
parts = {
|
|
111
|
+
PATH_PART: _part(operation.params.path, at.below("params", "path"), refusals),
|
|
112
|
+
QUERY_PART: _part(operation.params.query, at.below("params", "query"), refusals),
|
|
113
|
+
HEADER_PART: _part(handler_headers(operation), at.below("params", "header"), refusals),
|
|
114
|
+
PROOFS_PART: _proofs(operation, at.below("input", "proofs"), refusals),
|
|
115
|
+
}
|
|
116
|
+
members[operation.id] = {part: attributes for part, attributes in parts.items() if attributes}
|
|
117
|
+
|
|
118
|
+
return members
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _part(params: Sequence[Param], at: IrPlace, refusals: Refusals) -> dict[str, str]:
|
|
122
|
+
return {param.name: attribute for _, param, attribute in part_attributes(params, at, refusals)}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _proofs(operation: Operation, at: IrPlace, refusals: Refusals) -> dict[str, str]:
|
|
126
|
+
proofs = operation.input.proofs if operation.input is not None else None
|
|
127
|
+
attributes: dict[str, str] = {}
|
|
128
|
+
|
|
129
|
+
for index, proof in enumerate(proofs or ()):
|
|
130
|
+
attribute = member_identifier(proof.name)
|
|
131
|
+
|
|
132
|
+
if attribute is None:
|
|
133
|
+
refusals.inconsistent("name.segment.missing", f"The body proof '{proof.name}' has no name segment", at)
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if attribute in attributes.values():
|
|
137
|
+
refusals.add(
|
|
138
|
+
"name.collision",
|
|
139
|
+
f"Two body proofs of operation '{operation.id}' give the Python name '{attribute}'.",
|
|
140
|
+
"Rename one of the proofs in the contract.",
|
|
141
|
+
at.below(index),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
attributes[proof.name] = attribute
|
|
145
|
+
|
|
146
|
+
return attributes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The credentials class of each security identity, shared by the Python targets: its name, its attribute per scheme
|
|
2
|
+
the kept operations read, and the frozen class itself, one `str | None` per scheme, hidden from `repr`. The authenticate
|
|
3
|
+
hook receives an instance; the runtime builds it through `CredentialsType`."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Collection, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from seampact.codegen.names import member_identifier
|
|
10
|
+
from seampact.codegen.printer import INDENT, string_literal
|
|
11
|
+
from seampact.codegen.refusals import IrPlace, Refusals
|
|
12
|
+
from seampact.ir.model import ContractIR, IdentityAccess, OptionalAccess
|
|
13
|
+
from seampact.ir.naming import IrName, NameSource, NamingRole, python_identifier, segments_of
|
|
14
|
+
|
|
15
|
+
_REDACTED: Final = "[redacted]"
|
|
16
|
+
_CREDENTIALS_DOC: Final = (
|
|
17
|
+
"The credentials a request presents for identity '{identity}', one per scheme, None when absent."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# How a generator claims a class name of its module: the name, what takes it, and where; it answers the name.
|
|
21
|
+
type ClaimName = Callable[[str, str, IrPlace], str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class CredentialsNames:
|
|
26
|
+
"""The credentials class of one identity and its attribute per scheme, in scheme order."""
|
|
27
|
+
|
|
28
|
+
class_name: str
|
|
29
|
+
attributes: Mapping[str, str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def identity_credentials(
|
|
33
|
+
ir: ContractIR, kept_operations: Collection[str], claim: ClaimName, refusals: Refusals
|
|
34
|
+
) -> dict[str, CredentialsNames]:
|
|
35
|
+
"""Per identity the kept operations read, its credentials class and the schemes those operations read it through."""
|
|
36
|
+
schemes_by_identity: dict[str, dict[str, IrPlace]] = {}
|
|
37
|
+
|
|
38
|
+
for index, operation in enumerate(ir.operations):
|
|
39
|
+
access = operation.access
|
|
40
|
+
|
|
41
|
+
if operation.id in kept_operations and isinstance(access, IdentityAccess | OptionalAccess):
|
|
42
|
+
at = IrPlace(f"/operations/{index}", operation.id).below("access")
|
|
43
|
+
schemes = schemes_by_identity.setdefault(access.identity, {})
|
|
44
|
+
schemes.update({scheme: at.below("schemes") for scheme in access.schemes if scheme not in schemes})
|
|
45
|
+
|
|
46
|
+
credentials: dict[str, CredentialsNames] = {}
|
|
47
|
+
|
|
48
|
+
for identity, schemes in sorted(schemes_by_identity.items()):
|
|
49
|
+
segments = segments_of(identity, NameSource.NAME) or [identity]
|
|
50
|
+
class_name = claim(
|
|
51
|
+
python_identifier(IrName((*segments, "credentials"), NamingRole.SCHEMA)),
|
|
52
|
+
f"the credentials of identity '{identity}'",
|
|
53
|
+
IrPlace(f"/identities/{identity}"),
|
|
54
|
+
)
|
|
55
|
+
credentials[identity] = CredentialsNames(class_name, _scheme_attributes(identity, schemes, refusals))
|
|
56
|
+
|
|
57
|
+
return credentials
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def credentials_class(decorator: str, identity: str, names: CredentialsNames) -> list[str]:
|
|
61
|
+
"""The class block, under the generator's frozen dataclass `decorator` line."""
|
|
62
|
+
lines = [decorator, f"class {names.class_name}:", f'{INDENT}"""{_CREDENTIALS_DOC.format(identity=identity)}"""']
|
|
63
|
+
attributes = list(names.attributes.values())
|
|
64
|
+
|
|
65
|
+
if not attributes:
|
|
66
|
+
return lines
|
|
67
|
+
|
|
68
|
+
members = [f"{INDENT}{attribute}: str | None = None" for attribute in attributes]
|
|
69
|
+
rendered = ", ".join(f"{attribute}={_REDACTED}" for attribute in attributes)
|
|
70
|
+
redacted_repr = [
|
|
71
|
+
f"{INDENT}def __repr__(self) -> str:",
|
|
72
|
+
f"{INDENT * 2}return {string_literal(f'{names.class_name}({rendered})')}",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
return [*lines, "", *members, "", *redacted_repr]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _scheme_attributes(identity: str, schemes: Mapping[str, IrPlace], refusals: Refusals) -> dict[str, str]:
|
|
79
|
+
attributes: dict[str, str] = {}
|
|
80
|
+
|
|
81
|
+
for scheme, at in sorted(schemes.items()):
|
|
82
|
+
attribute = member_identifier(scheme)
|
|
83
|
+
|
|
84
|
+
if attribute is None:
|
|
85
|
+
refusals.inconsistent("scheme.no-segment", f"The scheme '{scheme}' yields no name segment", at)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if attribute in attributes.values():
|
|
89
|
+
refusals.add(
|
|
90
|
+
"name.collision",
|
|
91
|
+
f"The credentials of identity '{identity}' would hold two schemes as '{attribute}'.",
|
|
92
|
+
"Rename one of the schemes in the contract.",
|
|
93
|
+
at,
|
|
94
|
+
)
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
attributes[scheme] = attribute
|
|
98
|
+
|
|
99
|
+
return attributes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The forms the generated boundary validates but does not enforce, which the handler owes: each is named with a
|
|
2
|
+
warning at generation, as on the Node servers (ADR 0016 "Left to the handler"), never dropped silently. An identity's
|
|
3
|
+
request `headers` are validated as declared headers only, so the handler reads them. An identity header the server
|
|
4
|
+
would write on a response (a versionToken, or a header declared `in: response`) has no value a handler can write, a
|
|
5
|
+
reply carrying only a body, so it is refused by name at its declaration rather than dropped."""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from seampact.codegen.refusals import Delivery, IrPlace, Refusals, place_in_entry
|
|
11
|
+
from seampact.ir.config.problem import ConfigProblem
|
|
12
|
+
from seampact.ir.model import (
|
|
13
|
+
ContractIR,
|
|
14
|
+
CredentialIdentity,
|
|
15
|
+
HeaderDecl,
|
|
16
|
+
HeaderSemantic,
|
|
17
|
+
HeaderSide,
|
|
18
|
+
HeaderUseFor,
|
|
19
|
+
IdentityAccess,
|
|
20
|
+
IdentityHeaderUse,
|
|
21
|
+
Operation,
|
|
22
|
+
OptionalAccess,
|
|
23
|
+
SignedIdentity,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
IDENTITY_HEADERS_HANDLER: Final = "identity.headers.handler"
|
|
27
|
+
IDENTITY_RESPONSE_HEADER: Final = "identity.response.header"
|
|
28
|
+
_IDENTITY_HEADERS_FIX: Final = "Read the identity headers the boundary validated in the handler."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class _ResponseHeader:
|
|
33
|
+
header_id: str
|
|
34
|
+
name: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class _IdentityHeaders:
|
|
39
|
+
identity_id: str
|
|
40
|
+
request_side_uses: int
|
|
41
|
+
response_side: tuple[_ResponseHeader, ...]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def handler_warnings(ir: ContractIR, entry_id: str, api: str) -> list[ConfigProblem]:
|
|
45
|
+
"""One warning per operation whose identity declares request headers."""
|
|
46
|
+
warnings: list[ConfigProblem] = []
|
|
47
|
+
|
|
48
|
+
for index, operation in enumerate(ir.operations):
|
|
49
|
+
headers = _identity_headers(ir, operation)
|
|
50
|
+
|
|
51
|
+
if headers is None or headers.request_side_uses == 0:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
identity_id = headers.identity_id
|
|
55
|
+
|
|
56
|
+
place = IrPlace(f"/operations/{index}", operation.id).below("access")
|
|
57
|
+
warnings.append(
|
|
58
|
+
ConfigProblem(
|
|
59
|
+
IDENTITY_HEADERS_HANDLER,
|
|
60
|
+
f"The headers of the identity '{identity_id}' in operation '{operation.id}' at {place.pointer} is not "
|
|
61
|
+
"enforced by the generated boundary.",
|
|
62
|
+
_IDENTITY_HEADERS_FIX,
|
|
63
|
+
place_in_entry(entry_id, place, api),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return warnings
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def refuse_identity_response_headers(ir: ContractIR, refusals: Refusals) -> None:
|
|
71
|
+
"""Refuses by name each identity header an operation would write on its responses, at the header's declaration."""
|
|
72
|
+
for operation in ir.operations:
|
|
73
|
+
headers = _identity_headers(ir, operation)
|
|
74
|
+
|
|
75
|
+
if headers is None:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
for header in headers.response_side:
|
|
79
|
+
refusals.waiting(
|
|
80
|
+
IDENTITY_RESPONSE_HEADER,
|
|
81
|
+
f"The response header '{header.name}' of the identity '{headers.identity_id}'",
|
|
82
|
+
IrPlace(f"/headers/{header.header_id}", operation.id),
|
|
83
|
+
Delivery.AFTER_V1,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _identity_headers(ir: ContractIR, operation: Operation) -> _IdentityHeaders | None:
|
|
88
|
+
access = operation.access
|
|
89
|
+
|
|
90
|
+
if not isinstance(access, IdentityAccess | OptionalAccess):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
identity = ir.identities.get(access.identity)
|
|
94
|
+
|
|
95
|
+
if not isinstance(identity, CredentialIdentity | SignedIdentity) or not identity.headers:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
request_side_uses = 0
|
|
99
|
+
response_side: list[_ResponseHeader] = []
|
|
100
|
+
|
|
101
|
+
for use in identity.headers:
|
|
102
|
+
declaration = ir.headers.get(use.header)
|
|
103
|
+
|
|
104
|
+
if declaration is None or not _is_response_side(declaration):
|
|
105
|
+
request_side_uses += 1
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
name = _response_header_name(declaration, use, operation)
|
|
109
|
+
|
|
110
|
+
if name is not None:
|
|
111
|
+
response_side.append(_ResponseHeader(use.header, name))
|
|
112
|
+
|
|
113
|
+
return _IdentityHeaders(access.identity, request_side_uses, tuple(response_side))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_response_side(declaration: HeaderDecl) -> bool:
|
|
117
|
+
return declaration.semantic == HeaderSemantic.VERSION_TOKEN or declaration.in_ == HeaderSide.RESPONSE
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _response_header_name(declaration: HeaderDecl, use: IdentityHeaderUse, operation: Operation) -> str | None:
|
|
121
|
+
"""A versionToken answers on every success of the identity; another semantic only where its `for` applies. An
|
|
122
|
+
unknown `for` is taken to apply, so nothing is dropped unnamed."""
|
|
123
|
+
if declaration.semantic == HeaderSemantic.VERSION_TOKEN:
|
|
124
|
+
return declaration.response
|
|
125
|
+
|
|
126
|
+
if use.for_ == HeaderUseFor.READS and not operation.safe:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
if use.for_ == HeaderUseFor.WRITES and operation.safe:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
return declaration.name
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The parameter parts of each operation's input (ADR 0026 "Types and validation"), as object schemas the shared
|
|
2
|
+
Pydantic emitter writes beside the api's own: `<Op>Path`, `<Op>Query` and `<Op>Headers` hold the parameters the
|
|
3
|
+
handler reads, and `<Op>Proofs` the body's rule proofs, which the handler never reads. The placement runtime validates
|
|
4
|
+
each part of the boundary's decoded tree with its model in strict mode, and the runtime renames members through the
|
|
5
|
+
contract module's per-operation tables. Their ids (`<operationId>.path`) cannot be the id of a declared schema; a
|
|
6
|
+
class name taken twice is refused as `name.collision`."""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from seampact.codegen.kept_forms import handler_headers
|
|
12
|
+
from seampact.codegen.names import ApiNames
|
|
13
|
+
from seampact.codegen.pydantic_layout import ApiLayout
|
|
14
|
+
from seampact.ir.model import (
|
|
15
|
+
ContractIR,
|
|
16
|
+
Direction,
|
|
17
|
+
Docs,
|
|
18
|
+
Name,
|
|
19
|
+
NameRole,
|
|
20
|
+
ObjectSchema,
|
|
21
|
+
Operation,
|
|
22
|
+
Origin,
|
|
23
|
+
Param,
|
|
24
|
+
Property,
|
|
25
|
+
Schema,
|
|
26
|
+
TypeUse,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
INPUT_PARTS = (("path", NameRole.PATH), ("query", NameRole.QUERY), ("headers", NameRole.HEADERS))
|
|
30
|
+
PROOFS_PART = "proofs"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def input_schema_id(operation_id: str, part: str) -> str:
|
|
34
|
+
return f"{operation_id}.{part}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def with_input_schemas(ir: ContractIR, layout: ApiLayout) -> ContractIR:
|
|
38
|
+
"""A copy of `ir` whose schemas also hold each operation's parameter parts."""
|
|
39
|
+
schemas: dict[str, Schema] = dict(ir.schemas)
|
|
40
|
+
|
|
41
|
+
for index, operation in enumerate(ir.operations):
|
|
42
|
+
if operation.id not in layout.operations:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
origin = Origin(layer="api", pointer=f"/operations/{index}")
|
|
46
|
+
|
|
47
|
+
for part, role in INPUT_PARTS:
|
|
48
|
+
params = _part_params(operation, part)
|
|
49
|
+
|
|
50
|
+
if params:
|
|
51
|
+
part_id = input_schema_id(operation.id, part)
|
|
52
|
+
members = [_param_member(param, origin) for param in params]
|
|
53
|
+
schemas[part_id] = _object(part_id, operation, role, members, origin)
|
|
54
|
+
|
|
55
|
+
proofs = _proofs_object(operation, origin)
|
|
56
|
+
|
|
57
|
+
if proofs is not None:
|
|
58
|
+
schemas[proofs.id] = proofs
|
|
59
|
+
|
|
60
|
+
return dataclasses.replace(ir, schemas=schemas)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _proofs_object(operation: Operation, origin: Origin) -> ObjectSchema | None:
|
|
64
|
+
"""`<Op>Proofs`: the body's rule proofs, which step 6 validates beside the typed body and the handler never reads.
|
|
65
|
+
Every proof is required and sensitive (docs/ir section 13)."""
|
|
66
|
+
proofs = operation.input.proofs if operation.input is not None else None
|
|
67
|
+
|
|
68
|
+
if not proofs:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
members = [
|
|
72
|
+
dataclasses.replace(_member(proof.name, proof.schema, origin, optional=False), sensitive=True)
|
|
73
|
+
for proof in proofs
|
|
74
|
+
]
|
|
75
|
+
proofs_id = input_schema_id(operation.id, PROOFS_PART)
|
|
76
|
+
name = Name(segments=(*operation.name.segments, PROOFS_PART), role=NameRole.SCHEMA)
|
|
77
|
+
|
|
78
|
+
return dataclasses.replace(_object(proofs_id, operation, NameRole.SCHEMA, members, origin), name=name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _part_params(operation: Operation, part: str) -> Sequence[Param]:
|
|
82
|
+
if part == "path":
|
|
83
|
+
return operation.params.path
|
|
84
|
+
|
|
85
|
+
return operation.params.query if part == "query" else handler_headers(operation)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _object(
|
|
89
|
+
schema_id: str, operation: Operation, role: NameRole, members: list[Property], origin: Origin
|
|
90
|
+
) -> ObjectSchema:
|
|
91
|
+
return ObjectSchema(
|
|
92
|
+
properties=tuple(members),
|
|
93
|
+
id=schema_id,
|
|
94
|
+
name=Name(segments=tuple(operation.name.segments), role=role),
|
|
95
|
+
directions=(Direction.INPUT,),
|
|
96
|
+
examples=(),
|
|
97
|
+
docs=Docs(),
|
|
98
|
+
extensions={},
|
|
99
|
+
origin=origin,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _param_member(param: Param, origin: Origin) -> Property:
|
|
104
|
+
# A parameter's default goes to the emitter, which refuses a member default by name.
|
|
105
|
+
return dataclasses.replace(
|
|
106
|
+
_member(param.name, param.type, origin, optional=not param.required), default=param.default
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _member(name: str, type_use: TypeUse, origin: Origin, *, optional: bool) -> Property:
|
|
111
|
+
return Property(
|
|
112
|
+
name=name,
|
|
113
|
+
type=type_use,
|
|
114
|
+
optional=optional,
|
|
115
|
+
nullable=False,
|
|
116
|
+
sensitive=False,
|
|
117
|
+
deprecated=False,
|
|
118
|
+
docs=Docs(),
|
|
119
|
+
extensions={},
|
|
120
|
+
origin=origin,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def proof_models(layout: ApiLayout, names: ApiNames) -> dict[str, str]:
|
|
125
|
+
"""The `<Op>Proofs` class of each kept operation whose body carries rule proofs."""
|
|
126
|
+
return {
|
|
127
|
+
operation_id: names.schema_classes[input_schema_id(operation_id, PROOFS_PART)]
|
|
128
|
+
for operation_id in layout.operations
|
|
129
|
+
if input_schema_id(operation_id, PROOFS_PART) in names.schema_classes
|
|
130
|
+
}
|