messagefoundry 0.1.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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,1918 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Code-first wiring: declare **Connections** and decorate **Router**/**Handler** functions.
|
|
4
|
+
|
|
5
|
+
A config module (loaded from a directory via :func:`load_config`) declares named inbound/outbound
|
|
6
|
+
**Connections** and registers Router/Handler scripts — wired by name, with no enclosing "channel"
|
|
7
|
+
object::
|
|
8
|
+
|
|
9
|
+
from messagefoundry import inbound, outbound, router, handler, Send, MLLP, File
|
|
10
|
+
|
|
11
|
+
inbound("IB_Test_ADT", MLLP(port=2575), router="adt_router")
|
|
12
|
+
outbound("FILE-OUT_Test_ADT", File(directory="./out/adt"))
|
|
13
|
+
|
|
14
|
+
@router("adt_router")
|
|
15
|
+
def route(msg):
|
|
16
|
+
return ["archive"] if msg["MSH-9.1"] == "ADT" else [] # [] -> logged UNROUTED
|
|
17
|
+
|
|
18
|
+
@handler("archive")
|
|
19
|
+
def handle(msg):
|
|
20
|
+
if msg["MSH-9.2"] not in ("A01", "A04", "A08"):
|
|
21
|
+
return None # None -> logged FILTERED
|
|
22
|
+
msg["MSH-3"] = "FOUNDRY"
|
|
23
|
+
return Send("FILE-OUT_Test_ADT", msg)
|
|
24
|
+
|
|
25
|
+
This module only **declares** the graph (the registry); running it (inbound → router → handlers →
|
|
26
|
+
outbox → ACK) is the engine's job. Routers/Handlers are pure: they return where a message goes,
|
|
27
|
+
they never do network I/O (the outbox worker delivers, preserving at-least-once).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import importlib.util
|
|
34
|
+
import ipaddress
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
import threading
|
|
38
|
+
from collections.abc import Callable, Mapping
|
|
39
|
+
from contextlib import contextmanager, suppress
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Iterator
|
|
43
|
+
|
|
44
|
+
from messagefoundry.config.code_sets import (
|
|
45
|
+
CODESETS_DIR_NAME,
|
|
46
|
+
CodeSet,
|
|
47
|
+
CodeSetError,
|
|
48
|
+
activated as _code_sets_activated,
|
|
49
|
+
code_set as _resolve_code_set,
|
|
50
|
+
load_code_sets,
|
|
51
|
+
)
|
|
52
|
+
from messagefoundry.config.models import (
|
|
53
|
+
AckAfter,
|
|
54
|
+
AckMode,
|
|
55
|
+
BuildupThreshold,
|
|
56
|
+
ConnectorType,
|
|
57
|
+
ContentType,
|
|
58
|
+
InternalErrorPolicy,
|
|
59
|
+
OrderingMode,
|
|
60
|
+
RetryPolicy,
|
|
61
|
+
Validation,
|
|
62
|
+
)
|
|
63
|
+
from messagefoundry.parsing.message import Message, RawMessage
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"ConnectionSpec",
|
|
67
|
+
"MLLP",
|
|
68
|
+
"Tcp",
|
|
69
|
+
"X12",
|
|
70
|
+
"File",
|
|
71
|
+
"Timer",
|
|
72
|
+
"Loopback",
|
|
73
|
+
"Rest",
|
|
74
|
+
"Database",
|
|
75
|
+
"DatabasePoll",
|
|
76
|
+
"Soap",
|
|
77
|
+
"Sftp",
|
|
78
|
+
"Ftp",
|
|
79
|
+
"Send",
|
|
80
|
+
"SetState",
|
|
81
|
+
"EnvRef",
|
|
82
|
+
"env",
|
|
83
|
+
"CodeSet",
|
|
84
|
+
"code_set",
|
|
85
|
+
"Reference",
|
|
86
|
+
"FileRef",
|
|
87
|
+
"DatabaseRef",
|
|
88
|
+
"ReferenceSpec",
|
|
89
|
+
"ReferenceSourceSpec",
|
|
90
|
+
"resolve_env_settings",
|
|
91
|
+
"referenced_env_keys",
|
|
92
|
+
"display_settings",
|
|
93
|
+
"redacted_settings",
|
|
94
|
+
"InboundConnection",
|
|
95
|
+
"OutboundConnection",
|
|
96
|
+
"Registry",
|
|
97
|
+
"WiringError",
|
|
98
|
+
"Diagnostic",
|
|
99
|
+
"inbound",
|
|
100
|
+
"outbound",
|
|
101
|
+
"build_inbound_connection",
|
|
102
|
+
"build_outbound_connection",
|
|
103
|
+
"parse_env_setting",
|
|
104
|
+
"router",
|
|
105
|
+
"handler",
|
|
106
|
+
"load_config",
|
|
107
|
+
"validate_config",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class WiringError(ValueError):
|
|
112
|
+
"""A connection/router/handler was declared wrong, or references something missing."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Diagnostic:
|
|
117
|
+
"""One config problem, for tools (e.g. the IDE Problems panel) that want all errors at once."""
|
|
118
|
+
|
|
119
|
+
message: str
|
|
120
|
+
file: str | None = None
|
|
121
|
+
severity: str = "error"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class ConnectionSpec:
|
|
126
|
+
"""The transport bits of a Connection (type + settings); the logic lives in Router/Handler."""
|
|
127
|
+
|
|
128
|
+
type: ConnectorType
|
|
129
|
+
settings: dict[str, Any]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --- environment-specific values (DEV/PROD) ----------------------------------
|
|
133
|
+
|
|
134
|
+
#: Sentinel for "no default" so ``env("k", default=None)`` (a deliberate None) is distinguishable
|
|
135
|
+
#: from "unset" (which makes a missing value a hard load error).
|
|
136
|
+
_UNSET: Any = object()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class EnvRef:
|
|
141
|
+
"""A reference to an environment-specific value (e.g. a downstream host that differs DEV vs PROD).
|
|
142
|
+
|
|
143
|
+
The graph carries the *reference*; the engine resolves it against the running instance's
|
|
144
|
+
environment values when it builds the connector. One graph therefore runs in every environment,
|
|
145
|
+
and a referenced-but-undefined value fails **loud** at load/promote rather than silently
|
|
146
|
+
becoming a blank host (the classic Mirth ``${key}`` footgun). Authored via :func:`env`."""
|
|
147
|
+
|
|
148
|
+
key: str
|
|
149
|
+
default: Any = _UNSET
|
|
150
|
+
cast: Callable[[Any], Any] | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def env(key: str, *, default: Any = _UNSET, cast: Callable[[Any], Any] | None = None) -> EnvRef:
|
|
154
|
+
"""Reference an environment-specific value, resolved per running instance (DEV/PROD).
|
|
155
|
+
|
|
156
|
+
Use it inside a connection spec for anything that differs by environment — a downstream peer,
|
|
157
|
+
a path, a credential::
|
|
158
|
+
|
|
159
|
+
outbound("OB_EPIC_ADT", MLLP(host=env("epic_host"), port=env("epic_port", cast=int)))
|
|
160
|
+
|
|
161
|
+
Values come from the instance's environment: ``environments/<env>.toml`` (non-secrets, versioned)
|
|
162
|
+
overlaid with ``MEFOR_VALUE_<KEY>`` env vars (secrets). A referenced key with no value and no
|
|
163
|
+
``default`` makes the engine refuse to load/promote that graph — never a silent blank.
|
|
164
|
+
|
|
165
|
+
The key is matched case-insensitively (lower-cased here, as it is on the value side), so
|
|
166
|
+
``env("EPIC_HOST")``, the file key ``epic_host``, and ``MEFOR_VALUE_EPIC_HOST`` all line up."""
|
|
167
|
+
return EnvRef(key=key.lower(), default=default, cast=cast)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
#: Named casts a ``connections.toml`` env-ref may request (ADR 0007). A data file/GUI can't author an
|
|
171
|
+
#: arbitrary Python callable the way :func:`env` can, so the file form is restricted to these — and
|
|
172
|
+
#: ``int`` is the only cast used across the migration estate today.
|
|
173
|
+
_NAMED_CASTS: dict[str, Callable[[Any], Any]] = {
|
|
174
|
+
"int": int,
|
|
175
|
+
"float": float,
|
|
176
|
+
"bool": bool,
|
|
177
|
+
"str": str,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#: The only keys an env-ref inline table may carry (the inverse of :func:`display_settings`).
|
|
181
|
+
_ENVREF_KEYS = frozenset({"env", "default", "cast"})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_env_setting(value: Any) -> Any:
|
|
185
|
+
"""Decode one ``connections.toml`` settings value into a literal or an :class:`EnvRef` (ADR 0007).
|
|
186
|
+
|
|
187
|
+
An inline table carrying the reserved key ``env`` (and only ``env``/``default``/``cast``) becomes an
|
|
188
|
+
:class:`EnvRef` — the inverse of :func:`display_settings`'s ``{"env": key[, "default"]}`` encoding;
|
|
189
|
+
``cast`` is a **named** cast (``"int"``/``"float"``/``"bool"``/``"str"``) since a file can't carry a
|
|
190
|
+
Python callable. Any other value (a scalar, list, or a plain dict like a REST ``headers`` map) is
|
|
191
|
+
returned verbatim. Raises :class:`WiringError` on a malformed env marker or an unknown cast name."""
|
|
192
|
+
if not (isinstance(value, dict) and "env" in value and set(value) <= _ENVREF_KEYS):
|
|
193
|
+
return value
|
|
194
|
+
key = value["env"]
|
|
195
|
+
if not isinstance(key, str) or not key:
|
|
196
|
+
raise WiringError(f"env reference must name a non-empty string key, got {key!r}")
|
|
197
|
+
cast_name = value.get("cast")
|
|
198
|
+
if cast_name is not None and cast_name not in _NAMED_CASTS:
|
|
199
|
+
raise WiringError(
|
|
200
|
+
f"env reference {key!r}: unknown cast {cast_name!r} "
|
|
201
|
+
f"(use one of {', '.join(sorted(_NAMED_CASTS))})"
|
|
202
|
+
)
|
|
203
|
+
cast = _NAMED_CASTS[cast_name] if cast_name is not None else None
|
|
204
|
+
default = value["default"] if "default" in value else _UNSET
|
|
205
|
+
return EnvRef(key=key.lower(), default=default, cast=cast)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --- code sets (reference lookup tables) -------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def code_set(name: str) -> CodeSet:
|
|
212
|
+
"""Reference a managed reference table from ``codesets/<name>.{csv,toml}`` (next to ``--config``).
|
|
213
|
+
|
|
214
|
+
The code-first alternative to a hand-maintained dict: capture it once at a module's top level
|
|
215
|
+
(``DIET = code_set("epic_diets")``) or look it up at call time inside a handler
|
|
216
|
+
(``code_set("epic_diets").get(x)``) — both resolve against the active set the loader/runner has
|
|
217
|
+
published. Returns a frozen, read-only :class:`CodeSet` (a mapping: ``cs[k]`` / ``cs.get(k, d)`` /
|
|
218
|
+
``k in cs`` / ``len(cs)`` / iteration); it is shared across transforms, so it must not be mutated.
|
|
219
|
+
|
|
220
|
+
A missing or malformed code set fails **loud** as a :class:`WiringError`, surfaced by ``validate`` /
|
|
221
|
+
``check`` / reload exactly like a missing ``env()`` value — never a silent empty table. The
|
|
222
|
+
reference data is read-only, so the lookup stays pure (re-run-safe); see
|
|
223
|
+
:mod:`messagefoundry.config.code_sets` for the one reload-vs-re-run caveat."""
|
|
224
|
+
try:
|
|
225
|
+
return _resolve_code_set(name)
|
|
226
|
+
except CodeSetError as exc:
|
|
227
|
+
raise WiringError(str(exc)) from exc
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --- reference sets (external-data enrichment, ADR 0006 Tier 1) ---------------
|
|
231
|
+
# A reference set is declared in a wiring module with Reference(name, source=…); the engine's
|
|
232
|
+
# ReferenceSyncRunner materializes the source OFF the message path into a versioned, encrypted store
|
|
233
|
+
# snapshot, and a Handler reads it PURELY at run time via reference("name").get(key) (the read accessor
|
|
234
|
+
# lives in messagefoundry.config.reference). The DECLARATION here is the source + cadence only.
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True)
|
|
238
|
+
class ReferenceSourceSpec:
|
|
239
|
+
"""Where a reference set's data is materialized from (the analog of :class:`ConnectionSpec`).
|
|
240
|
+
|
|
241
|
+
``kind`` selects the source connector (``"file"`` today; ``"database"`` is ADR-0006 increment 2);
|
|
242
|
+
``settings`` carries its options (may hold :class:`EnvRef` values, resolved per environment)."""
|
|
243
|
+
|
|
244
|
+
kind: str
|
|
245
|
+
settings: dict[str, Any]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def FileRef(
|
|
249
|
+
*,
|
|
250
|
+
path: str | EnvRef,
|
|
251
|
+
encoding: str = "utf-8",
|
|
252
|
+
) -> ReferenceSourceSpec:
|
|
253
|
+
"""A reference **source** backed by a local CSV/TOML file (ADR 0006 Tier 1).
|
|
254
|
+
|
|
255
|
+
The file has the same shape as a code set (``code_set`` format: header row, first column the key;
|
|
256
|
+
one value column → scalar, several → ``{header: cell}``; or a flat/nested TOML). It is the path for
|
|
257
|
+
an externally-produced export (e.g. a nightly job dumps a provider directory to a share): the engine
|
|
258
|
+
re-reads it on the set's refresh cadence and materializes it into a versioned, encrypted snapshot,
|
|
259
|
+
so an updated export is picked up without a config reload. ``path`` may be an :func:`env` ref."""
|
|
260
|
+
return ReferenceSourceSpec("file", {"path": path, "encoding": encoding})
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def DatabaseRef(
|
|
264
|
+
*,
|
|
265
|
+
server: str | EnvRef,
|
|
266
|
+
database: str | EnvRef,
|
|
267
|
+
statement: str,
|
|
268
|
+
key_column: str,
|
|
269
|
+
value_column: str | None = None,
|
|
270
|
+
auth: str = "sql",
|
|
271
|
+
username: str | EnvRef | None = None,
|
|
272
|
+
password: str | EnvRef | None = None,
|
|
273
|
+
port: int | EnvRef = 1433,
|
|
274
|
+
encrypt: bool = True,
|
|
275
|
+
trust_server_certificate: bool = False,
|
|
276
|
+
connect_timeout: int = 15,
|
|
277
|
+
app_name: str = "messagefoundry",
|
|
278
|
+
odbc_driver: str = "ODBC Driver 18 for SQL Server",
|
|
279
|
+
pool_max: int = 5,
|
|
280
|
+
) -> ReferenceSourceSpec:
|
|
281
|
+
"""A reference **source** backed by a SQL query (ADR 0006 increment 2; SQL Server via the
|
|
282
|
+
``[sqlserver]`` extra + ODBC Driver 18 — **production / supported**, like the DATABASE connector).
|
|
283
|
+
|
|
284
|
+
The engine runs ``statement`` (a read-only ``SELECT``/proc) on the set's refresh cadence and builds
|
|
285
|
+
the snapshot from the rows: ``key_column`` is the lookup key; ``value_column`` (if given) is that
|
|
286
|
+
column's value, else the value is a dict of the remaining columns (the multi-column ``code_set``
|
|
287
|
+
shape). Put secrets (``password``) in :func:`env`. TLS is on by default; weakening it needs
|
|
288
|
+
``MEFOR_ALLOW_INSECURE_TLS``. The dial-out is gated by the **fail-closed** ``[egress].allowed_db``
|
|
289
|
+
allowlist, exactly like a DATABASE poll source — point the engine only at allowed hosts."""
|
|
290
|
+
return ReferenceSourceSpec(
|
|
291
|
+
"database",
|
|
292
|
+
{
|
|
293
|
+
"server": server,
|
|
294
|
+
"database": database,
|
|
295
|
+
"statement": statement,
|
|
296
|
+
"key_column": key_column,
|
|
297
|
+
"value_column": value_column,
|
|
298
|
+
"auth": auth,
|
|
299
|
+
"username": username,
|
|
300
|
+
"password": password,
|
|
301
|
+
"port": port,
|
|
302
|
+
"encrypt": encrypt,
|
|
303
|
+
"trust_server_certificate": trust_server_certificate,
|
|
304
|
+
"connect_timeout": connect_timeout,
|
|
305
|
+
"app_name": app_name,
|
|
306
|
+
"odbc_driver": odbc_driver,
|
|
307
|
+
"pool_max": pool_max,
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class ReferenceSpec:
|
|
314
|
+
"""A declared reference set: ``name`` + its :class:`ReferenceSourceSpec` + sync cadence.
|
|
315
|
+
|
|
316
|
+
Held in :class:`Registry` and consumed by the engine's ``ReferenceSyncRunner``; the data lives in
|
|
317
|
+
the store, read via ``reference(name)``. ``refresh_seconds`` is the materialization cadence (the
|
|
318
|
+
runner also syncs once on startup); ``max_staleness_seconds`` (0 = off) is a reserved freshness
|
|
319
|
+
knob for a follow-up."""
|
|
320
|
+
|
|
321
|
+
name: str
|
|
322
|
+
source: ReferenceSourceSpec
|
|
323
|
+
refresh_seconds: float = 3600.0
|
|
324
|
+
max_staleness_seconds: float = 0.0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def Reference(
|
|
328
|
+
name: str,
|
|
329
|
+
*,
|
|
330
|
+
source: ReferenceSourceSpec,
|
|
331
|
+
refresh_seconds: float = 3600.0,
|
|
332
|
+
max_staleness_seconds: float = 0.0,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Declare a reference set into the graph being loaded (side-effecting, like :func:`inbound`).
|
|
335
|
+
|
|
336
|
+
The engine materializes ``source`` into a versioned snapshot every ``refresh_seconds`` (and once at
|
|
337
|
+
startup); a Handler reads it purely with ``reference(name).get(key)``. Example::
|
|
338
|
+
|
|
339
|
+
Reference("provider_npi", source=FileRef(path=env("provider_npi_csv")), refresh_seconds=3600)
|
|
340
|
+
"""
|
|
341
|
+
if refresh_seconds < 0:
|
|
342
|
+
raise WiringError(f"Reference({name!r}): refresh_seconds must be >= 0")
|
|
343
|
+
_active_registry().add_reference(
|
|
344
|
+
ReferenceSpec(
|
|
345
|
+
name=name,
|
|
346
|
+
source=source,
|
|
347
|
+
refresh_seconds=refresh_seconds,
|
|
348
|
+
max_staleness_seconds=max_staleness_seconds,
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# --- live lookup connections (handler-callable db_lookup, ADR 0010) -----------
|
|
354
|
+
# A DatabaseLookup declares a NAMED, read-only database connection a Handler queries LIVE at run time via
|
|
355
|
+
# db_lookup(name, statement, params) (the read accessor lives in messagefoundry.config.db_lookup). Unlike
|
|
356
|
+
# a reference set (a synced snapshot read purely), there is no statement or cadence here — only the
|
|
357
|
+
# connection; each call supplies its own statement. The engine builds one pooled executor from these.
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass(frozen=True)
|
|
361
|
+
class DatabaseLookupSpec:
|
|
362
|
+
"""A declared live-lookup database connection: ``name`` + connection ``settings`` (no statement — the
|
|
363
|
+
statement is supplied per :func:`~messagefoundry.config.db_lookup.db_lookup` call). ``settings`` may
|
|
364
|
+
hold :class:`EnvRef` values (put secrets like ``password`` in :func:`env`)."""
|
|
365
|
+
|
|
366
|
+
name: str
|
|
367
|
+
settings: dict[str, Any]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def DatabaseLookup(
|
|
371
|
+
name: str,
|
|
372
|
+
*,
|
|
373
|
+
server: str | EnvRef,
|
|
374
|
+
database: str | EnvRef,
|
|
375
|
+
auth: str = "sql",
|
|
376
|
+
username: str | EnvRef | None = None,
|
|
377
|
+
password: str | EnvRef | None = None,
|
|
378
|
+
port: int | EnvRef = 1433,
|
|
379
|
+
encrypt: bool = True,
|
|
380
|
+
trust_server_certificate: bool = False,
|
|
381
|
+
connect_timeout: int = 15,
|
|
382
|
+
app_name: str = "messagefoundry",
|
|
383
|
+
odbc_driver: str = "ODBC Driver 18 for SQL Server",
|
|
384
|
+
pool_max: int = 5,
|
|
385
|
+
acquire_timeout: float = 30.0, # cap a pooled-connection borrow (s) — fail transiently, not forever
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Declare a named live-lookup database connection (SQL Server via the ``[sqlserver]`` extra + ODBC
|
|
388
|
+
Driver 18 — **production / supported**, like the DATABASE connector). A Handler queries it at run time with
|
|
389
|
+
``db_lookup(name, statement, params)`` (a read-only ``SELECT``/proc); the rows come back as
|
|
390
|
+
``{column: value}`` dicts. Side-effecting, like :func:`Reference`/:func:`inbound`.
|
|
391
|
+
|
|
392
|
+
Put secrets (``password``) in :func:`env`. TLS is on by default; weakening it needs
|
|
393
|
+
``MEFOR_ALLOW_INSECURE_TLS``. The dial-out is gated by the **fail-closed** ``[egress].allowed_db``
|
|
394
|
+
allowlist, like a DATABASE source — point the engine only at allowed hosts. Example::
|
|
395
|
+
|
|
396
|
+
DatabaseLookup("clarity", server=env("clarity_host"), database="Clarity",
|
|
397
|
+
username=env("clarity_user"), password=env("clarity_pw"))
|
|
398
|
+
"""
|
|
399
|
+
_active_registry().add_lookup(
|
|
400
|
+
DatabaseLookupSpec(
|
|
401
|
+
name,
|
|
402
|
+
{
|
|
403
|
+
"server": server,
|
|
404
|
+
"database": database,
|
|
405
|
+
"auth": auth,
|
|
406
|
+
"username": username,
|
|
407
|
+
"password": password,
|
|
408
|
+
"port": port,
|
|
409
|
+
"encrypt": encrypt,
|
|
410
|
+
"trust_server_certificate": trust_server_certificate,
|
|
411
|
+
"connect_timeout": connect_timeout,
|
|
412
|
+
"app_name": app_name,
|
|
413
|
+
"odbc_driver": odbc_driver,
|
|
414
|
+
"pool_max": pool_max,
|
|
415
|
+
"acquire_timeout": acquire_timeout,
|
|
416
|
+
},
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def resolve_env_settings(settings: Mapping[str, Any], values: Mapping[str, Any]) -> dict[str, Any]:
|
|
422
|
+
"""Return a copy of ``settings`` with every :class:`EnvRef` resolved against ``values``.
|
|
423
|
+
|
|
424
|
+
Resolution order per ref: the environment value (cast if a ``cast`` was given), else its
|
|
425
|
+
``default``, else it's *missing*. Raises a single :class:`WiringError` listing **all** problems
|
|
426
|
+
at once — both missing keys and values that fail their ``cast`` (naming setting/key/value) — so
|
|
427
|
+
the failure is loud and actionable, not a raw ``ValueError`` traceback that names nothing and
|
|
428
|
+
aborts on the first bad value (fail loud, never blank; review M-22)."""
|
|
429
|
+
resolved: dict[str, Any] = {}
|
|
430
|
+
missing: list[str] = []
|
|
431
|
+
bad: list[str] = []
|
|
432
|
+
for name, value in settings.items():
|
|
433
|
+
if isinstance(value, EnvRef):
|
|
434
|
+
if value.key in values:
|
|
435
|
+
raw = values[value.key]
|
|
436
|
+
if value.cast is None:
|
|
437
|
+
resolved[name] = raw
|
|
438
|
+
else:
|
|
439
|
+
try:
|
|
440
|
+
resolved[name] = value.cast(raw)
|
|
441
|
+
except (ValueError, TypeError) as exc:
|
|
442
|
+
bad.append(f"setting {name!r} (env {value.key!r}={raw!r}): {exc}")
|
|
443
|
+
elif value.default is not _UNSET:
|
|
444
|
+
resolved[name] = value.default
|
|
445
|
+
else:
|
|
446
|
+
missing.append(value.key)
|
|
447
|
+
else:
|
|
448
|
+
resolved[name] = value
|
|
449
|
+
problems: list[str] = []
|
|
450
|
+
if missing:
|
|
451
|
+
problems.append("missing: " + ", ".join(sorted(set(missing))))
|
|
452
|
+
if bad:
|
|
453
|
+
problems.append("uncastable: " + "; ".join(bad))
|
|
454
|
+
if problems:
|
|
455
|
+
raise WiringError(
|
|
456
|
+
"environment value(s) unusable — "
|
|
457
|
+
+ "; ".join(problems)
|
|
458
|
+
+ " — set/fix them in this environment's values (environments/<env>.toml or MEFOR_VALUE_*)"
|
|
459
|
+
)
|
|
460
|
+
return resolved
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def referenced_env_keys(settings: Mapping[str, Any]) -> list[str]:
|
|
464
|
+
"""The environment keys a settings dict references (sorted, de-duplicated) — for tooling."""
|
|
465
|
+
return sorted({v.key for v in settings.values() if isinstance(v, EnvRef)})
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def display_settings(settings: Mapping[str, Any]) -> dict[str, Any]:
|
|
469
|
+
"""A JSON-safe view of settings for introspection: each EnvRef becomes ``{"env": key[, default]}``."""
|
|
470
|
+
out: dict[str, Any] = {}
|
|
471
|
+
for name, value in settings.items():
|
|
472
|
+
if isinstance(value, EnvRef):
|
|
473
|
+
ref: dict[str, Any] = {"env": value.key}
|
|
474
|
+
if value.default is not _UNSET:
|
|
475
|
+
ref["default"] = value.default
|
|
476
|
+
out[name] = ref
|
|
477
|
+
else:
|
|
478
|
+
out[name] = value
|
|
479
|
+
return out
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
#: Settings keys whose values are credentials — redacted in the API metadata view. Secrets are
|
|
483
|
+
#: required to be ``env()`` refs (so they already render as ``{"env": ...}``); this is defence in
|
|
484
|
+
#: depth against an inline value, and it suppresses an ``env()`` *default* for a secret field. Covers
|
|
485
|
+
#: every credential-bearing connector setting (HTTP auth, DB user/password, SFTP key + passphrase).
|
|
486
|
+
_SECRET_SETTING_KEYS = frozenset(
|
|
487
|
+
{
|
|
488
|
+
"password",
|
|
489
|
+
"username",
|
|
490
|
+
"bearer_token",
|
|
491
|
+
"basic_password",
|
|
492
|
+
"basic_user",
|
|
493
|
+
"key_password",
|
|
494
|
+
"private_key",
|
|
495
|
+
"api_key",
|
|
496
|
+
"token",
|
|
497
|
+
}
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
#: Header names whose value is a credential — redacted inside a REST/SOAP ``headers`` table (the
|
|
501
|
+
#: project requires secrets via ``env()`` bearer/basic settings, not inline headers; this is defence
|
|
502
|
+
#: in depth for an operator who hard-codes one anyway). Compared case-insensitively.
|
|
503
|
+
_SECRET_HEADER_NAMES = frozenset(
|
|
504
|
+
{"authorization", "proxy-authorization", "x-api-key", "api-key", "cookie"}
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def redacted_settings(settings: Mapping[str, Any]) -> dict[str, Any]:
|
|
509
|
+
"""A JSON-safe, secret-scrubbed view of a connection's settings for the API ``/metadata`` endpoint:
|
|
510
|
+
each EnvRef becomes ``{"env": key}`` (the value is never resolved — only the key is shown), a
|
|
511
|
+
credential field rendered inline is replaced with ``"***"`` (an ``env()`` *default* is dropped for
|
|
512
|
+
a credential field so a fallback secret can't leak), and a credential header inside a ``headers``
|
|
513
|
+
table is redacted too."""
|
|
514
|
+
out: dict[str, Any] = {}
|
|
515
|
+
for name, value in settings.items():
|
|
516
|
+
is_secret = name in _SECRET_SETTING_KEYS
|
|
517
|
+
if isinstance(value, EnvRef):
|
|
518
|
+
ref: dict[str, Any] = {"env": value.key}
|
|
519
|
+
if value.default is not _UNSET and not is_secret:
|
|
520
|
+
ref["default"] = value.default
|
|
521
|
+
out[name] = ref
|
|
522
|
+
elif is_secret:
|
|
523
|
+
out[name] = "***"
|
|
524
|
+
elif name == "headers" and isinstance(value, dict):
|
|
525
|
+
out[name] = {
|
|
526
|
+
k: ("***" if str(k).lower() in _SECRET_HEADER_NAMES else v)
|
|
527
|
+
for k, v in value.items()
|
|
528
|
+
}
|
|
529
|
+
else:
|
|
530
|
+
out[name] = value
|
|
531
|
+
return out
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def MLLP(
|
|
535
|
+
*,
|
|
536
|
+
host: str | EnvRef | None = None, # OUTBOUND: the downstream peer (required; may be env()).
|
|
537
|
+
# INBOUND: omit — the bind interface is a service setting ([inbound].bind_host), not authored.
|
|
538
|
+
port: int | EnvRef,
|
|
539
|
+
encoding: str = "utf-8",
|
|
540
|
+
# Inbound DoS guards (defaults mirror transports.mllp.DEFAULT_*; pass None/0 to disable):
|
|
541
|
+
max_connections: int | None = 256, # cap concurrent clients (connection-flood guard)
|
|
542
|
+
receive_timeout: float | None = 60.0, # close a client idle this many seconds (slowloris)
|
|
543
|
+
max_frame_bytes: int | None = 16 * 1024 * 1024, # cap one frame's bytes (OOM guard); both dirs
|
|
544
|
+
connect_timeout: float = 10.0, # outbound: TCP connect timeout (seconds)
|
|
545
|
+
timeout_seconds: float = 30.0, # outbound: wait this long for the ACK
|
|
546
|
+
encoding_characters: str | None = None, # OUTBOUND: re-encode MSH-1/MSH-2 delimiters per dest
|
|
547
|
+
capture_response: bool = False, # outbound: capture the application ACK (MSA/ERR) as a reply (ADR 0013)
|
|
548
|
+
reingress_to: str
|
|
549
|
+
| None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
|
|
550
|
+
# --- TLS (WP-13b, ADR 0002) — per-connection MLLP-over-TLS ---
|
|
551
|
+
tls: bool = False, # turn TLS on (inbound: present a server cert; outbound: verify the peer)
|
|
552
|
+
tls_cert_file: str
|
|
553
|
+
| None = None, # inbound: SERVER cert (required when tls); outbound: CLIENT cert (mTLS)
|
|
554
|
+
tls_key_file: str | None = None, # private key for tls_cert_file
|
|
555
|
+
tls_ca_file: str
|
|
556
|
+
| None = None, # trust anchor — inbound: verify client certs (mTLS); outbound: verify server
|
|
557
|
+
tls_verify: bool = True, # OUTBOUND: verify the server cert (false is MITM-able → needs MEFOR_ALLOW_INSECURE_TLS)
|
|
558
|
+
tls_check_hostname: bool = True, # OUTBOUND: require the server cert to match `host`
|
|
559
|
+
) -> ConnectionSpec:
|
|
560
|
+
"""An MLLP endpoint. Inbound uses port/max_connections/receive_timeout/max_frame_bytes (the
|
|
561
|
+
bind interface comes from the service's ``[inbound].bind_host``, so ``host`` is rejected on an
|
|
562
|
+
inbound); outbound uses host/port/connect_timeout/timeout_seconds/max_frame_bytes. ``encoding``
|
|
563
|
+
applies to framing in both directions. ``capture_response`` (outbound, ADR 0013) records the
|
|
564
|
+
application ACK as a captured reply (a negative ACK still dead-letters/retries unchanged).
|
|
565
|
+
|
|
566
|
+
``encoding_characters`` (**outbound only**, Corepoint ``MsgSend -override component`` parity) makes
|
|
567
|
+
this destination re-encode each outgoing message with a different set of HL7 delimiters before
|
|
568
|
+
framing. Give the **5 MSH delimiter characters in MSH order** — MSH-1 (field separator) followed by
|
|
569
|
+
the four MSH-2 characters (component, repetition, escape, subcomponent) — e.g. the HL7 default is
|
|
570
|
+
``"|^~\\\\&"``. The connector parses the payload with its *current* (MSH-derived) delimiters,
|
|
571
|
+
rewrites MSH-1/MSH-2, and re-serializes the whole body with the new ones, so a downstream re-parse
|
|
572
|
+
yields the same logical fields under the new delimiters. ``None`` (the default) leaves the payload
|
|
573
|
+
**byte-identical** — fully backward compatible. The string is validated at connector build (exactly
|
|
574
|
+
five characters, all distinct); a non-HL7 payload that can't be parsed fails the delivery loud
|
|
575
|
+
(``DeliveryError``) rather than being silently corrupted.
|
|
576
|
+
|
|
577
|
+
**TLS (WP-13b).** ``tls=True`` wraps the connection: inbound presents ``tls_cert_file``/``tls_key_file``
|
|
578
|
+
(a server identity; ``tls_ca_file`` adds opt-in mTLS — require + verify a client cert); outbound
|
|
579
|
+
verifies the server cert against ``tls_ca_file`` (or the system trust store) with hostname checking,
|
|
580
|
+
and may present ``tls_cert_file`` for mTLS. ``tls_verify=False`` (outbound) is MITM-able and refused
|
|
581
|
+
unless ``MEFOR_ALLOW_INSECURE_TLS`` is set (loud warning) — exactly like LDAPS / SQL Server. TLS is
|
|
582
|
+
TLS 1.2+ and composes with the ``[egress].allowed_mllp`` allowlist (both enforced)."""
|
|
583
|
+
return ConnectionSpec(
|
|
584
|
+
ConnectorType.MLLP,
|
|
585
|
+
{
|
|
586
|
+
"host": host,
|
|
587
|
+
"port": port,
|
|
588
|
+
"encoding": encoding,
|
|
589
|
+
"max_connections": max_connections,
|
|
590
|
+
"receive_timeout": receive_timeout,
|
|
591
|
+
"max_frame_bytes": max_frame_bytes,
|
|
592
|
+
"connect_timeout": connect_timeout,
|
|
593
|
+
"timeout_seconds": timeout_seconds,
|
|
594
|
+
"encoding_characters": encoding_characters,
|
|
595
|
+
"capture_response": capture_response,
|
|
596
|
+
"reingress_to": reingress_to,
|
|
597
|
+
"tls": tls,
|
|
598
|
+
"tls_cert_file": tls_cert_file,
|
|
599
|
+
"tls_key_file": tls_key_file,
|
|
600
|
+
"tls_ca_file": tls_ca_file,
|
|
601
|
+
"tls_verify": tls_verify,
|
|
602
|
+
"tls_check_hostname": tls_check_hostname,
|
|
603
|
+
},
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def Tcp(
|
|
608
|
+
*,
|
|
609
|
+
host: str | EnvRef | None = None, # OUTBOUND: the downstream peer (required; may be env()).
|
|
610
|
+
# INBOUND: omit — the bind interface is a service setting ([inbound].bind_host), not authored.
|
|
611
|
+
port: int | EnvRef,
|
|
612
|
+
# Framing: a preset name ("stx_etx" | "vt_fs" | "mllp") OR explicit start/end[/trailer] byte ints.
|
|
613
|
+
framing: str | None = "stx_etx",
|
|
614
|
+
start: int | None = None, # explicit start delimiter byte (use instead of `framing`)
|
|
615
|
+
end: int | None = None, # explicit end delimiter byte
|
|
616
|
+
trailer: int | None = None, # explicit optional trailer byte
|
|
617
|
+
encoding: str = "utf-8",
|
|
618
|
+
# Inbound DoS guards (defaults mirror MLLP; pass None/0 to disable):
|
|
619
|
+
max_connections: int | None = 256, # cap concurrent clients (connection-flood guard)
|
|
620
|
+
receive_timeout: float | None = 60.0, # close a client idle this many seconds (slowloris)
|
|
621
|
+
max_frame_bytes: int | None = 16 * 1024 * 1024, # cap one frame's bytes (OOM guard); both dirs
|
|
622
|
+
connect_timeout: float = 10.0, # outbound: TCP connect timeout (seconds)
|
|
623
|
+
timeout_seconds: float = 30.0, # outbound: send/await-reply timeout
|
|
624
|
+
expect_reply: bool = False, # outbound: read one framed reply and treat it as confirmation
|
|
625
|
+
capture_response: bool = False, # outbound: capture the framed reply (requires expect_reply, ADR 0013)
|
|
626
|
+
reingress_to: str
|
|
627
|
+
| None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
|
|
628
|
+
) -> ConnectionSpec:
|
|
629
|
+
"""A raw-TCP endpoint with **configurable delimiter framing**, relaying the payload **opaquely**
|
|
630
|
+
(no structured parse) — built for X12-over-TCP feeds. Set ``framing`` to a preset
|
|
631
|
+
(``"stx_etx"`` = ``0x02``/``0x03``, the default; ``"vt_fs"``/``"mllp"`` = ``0x0B``/``0x1C``/``0x0D``)
|
|
632
|
+
**or** give explicit ``start``/``end`` (with optional ``trailer``) delimiter byte ints — not both.
|
|
633
|
+
|
|
634
|
+
Inbound takes no ``host`` (the bind interface is ``[inbound].bind_host``); pair it with
|
|
635
|
+
``content_type="x12"`` on ``inbound(...)`` so the body routes as a ``RawMessage`` (ADR 0004).
|
|
636
|
+
There is **no HL7 ACK** — a Handler may still return a payload, which is framed back to the
|
|
637
|
+
sender. Outbound dials ``host``/``port``, frames + sends; with ``expect_reply`` it waits for one
|
|
638
|
+
framed reply and treats receiving it as confirmation (the reply is **not** parsed — X12 997/TA1
|
|
639
|
+
acks are a deferred follow-up). Delivery is at-least-once → the receiver **must be idempotent**."""
|
|
640
|
+
return ConnectionSpec(
|
|
641
|
+
ConnectorType.TCP,
|
|
642
|
+
{
|
|
643
|
+
"host": host,
|
|
644
|
+
"port": port,
|
|
645
|
+
"framing": framing,
|
|
646
|
+
"start": start,
|
|
647
|
+
"end": end,
|
|
648
|
+
"trailer": trailer,
|
|
649
|
+
"encoding": encoding,
|
|
650
|
+
"max_connections": max_connections,
|
|
651
|
+
"receive_timeout": receive_timeout,
|
|
652
|
+
"max_frame_bytes": max_frame_bytes,
|
|
653
|
+
"connect_timeout": connect_timeout,
|
|
654
|
+
"timeout_seconds": timeout_seconds,
|
|
655
|
+
"expect_reply": expect_reply,
|
|
656
|
+
"capture_response": capture_response,
|
|
657
|
+
"reingress_to": reingress_to,
|
|
658
|
+
},
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def X12(
|
|
663
|
+
*,
|
|
664
|
+
host: str | EnvRef | None = None, # OUTBOUND: the downstream peer (required; may be env()).
|
|
665
|
+
# INBOUND: omit — the bind interface is a service setting ([inbound].bind_host), not authored.
|
|
666
|
+
port: int | EnvRef,
|
|
667
|
+
encoding: str = "utf-8",
|
|
668
|
+
# Inbound DoS guards (defaults mirror MLLP/TCP; pass None/0 to disable):
|
|
669
|
+
max_connections: int | None = 256, # cap concurrent clients (connection-flood guard)
|
|
670
|
+
receive_timeout: float | None = 60.0, # close a client idle this many seconds (slowloris)
|
|
671
|
+
max_interchange_bytes: int | None = 16
|
|
672
|
+
* 1024
|
|
673
|
+
* 1024, # cap one interchange's bytes (OOM); both dirs
|
|
674
|
+
connect_timeout: float = 10.0, # outbound: TCP connect timeout (seconds)
|
|
675
|
+
timeout_seconds: float = 30.0, # outbound: send/await-reply timeout
|
|
676
|
+
expect_reply: bool = False, # outbound: read one returned interchange and treat it as confirmation
|
|
677
|
+
# --- ADR 0016: synchronous request/response (real-time eligibility 270/271, 278N, 277) ---
|
|
678
|
+
capture_response: bool = False, # capture the returned interchange (271/TA1) as a reply (ADR 0013)
|
|
679
|
+
reingress_to: str
|
|
680
|
+
| None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
|
|
681
|
+
ta1_required: bool = False, # outbound: a delivery that reads no TA1/business reply is a retry
|
|
682
|
+
) -> ConnectionSpec:
|
|
683
|
+
"""A raw-TCP **ASC X12 EDI** endpoint (ADR 0012), framed by the interchange itself (``ISA…IEA``) —
|
|
684
|
+
there are **no delimiter-framing knobs**: the segment terminator is discovered from each ISA header.
|
|
685
|
+
Use this when the interchange is the frame; for partners who wrap each interchange in a fixed
|
|
686
|
+
sentinel (STX/ETX, VT/FS) use ``Tcp(framing=...)`` instead.
|
|
687
|
+
|
|
688
|
+
Inbound takes no ``host`` (the bind interface is ``[inbound].bind_host``); pair it with
|
|
689
|
+
``content_type="x12"`` on ``inbound(...)`` so the body routes as a ``RawMessage`` (ADR 0004) that a
|
|
690
|
+
Router/Handler parses on demand via ``messagefoundry.parsing.x12``. The inbound is an opaque relay
|
|
691
|
+
(no TA1/997/999). Outbound dials ``host``/``port`` and writes the interchange verbatim; with
|
|
692
|
+
``expect_reply`` it waits for one returned interchange as confirmation (not parsed). **Synchronous
|
|
693
|
+
request/response** (ADR 0016): set ``capture_response`` (or ``reingress_to=`` a ``Loopback()``
|
|
694
|
+
inbound) to capture the returned **271/TA1** as a reply — a **TA1** interchange acknowledgement is
|
|
695
|
+
classified (TA1*A → accepted; TA1*R → permanent reject/dead-letter; TA1*E → accepted-with-warning,
|
|
696
|
+
*not* retried), a business 271/277/278 returned instead is itself the confirmation; ``ta1_required``
|
|
697
|
+
makes a no-reply a retry. Egress is gated by ``[egress].allowed_tcp`` (X12 shares the raw-TCP
|
|
698
|
+
allowlist). Delivery is at-least-once → the receiver **must be idempotent** (a crash-re-send of a
|
|
699
|
+
non-idempotent 270 yields a fresh 271 captured at the next ``response_seq``)."""
|
|
700
|
+
return ConnectionSpec(
|
|
701
|
+
ConnectorType.X12,
|
|
702
|
+
{
|
|
703
|
+
"host": host,
|
|
704
|
+
"port": port,
|
|
705
|
+
"encoding": encoding,
|
|
706
|
+
"max_connections": max_connections,
|
|
707
|
+
"receive_timeout": receive_timeout,
|
|
708
|
+
"max_interchange_bytes": max_interchange_bytes,
|
|
709
|
+
"connect_timeout": connect_timeout,
|
|
710
|
+
"timeout_seconds": timeout_seconds,
|
|
711
|
+
"expect_reply": expect_reply,
|
|
712
|
+
"capture_response": capture_response,
|
|
713
|
+
"reingress_to": reingress_to,
|
|
714
|
+
"ta1_required": ta1_required,
|
|
715
|
+
},
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def File(
|
|
720
|
+
*,
|
|
721
|
+
directory: str | EnvRef,
|
|
722
|
+
filename: str | EnvRef = "{MSH-10}.hl7",
|
|
723
|
+
pattern: str = "*.hl7",
|
|
724
|
+
poll_seconds: float = 1.0,
|
|
725
|
+
encoding: str = "utf-8",
|
|
726
|
+
min_age_seconds: float = 0.0, # inbound: skip files modified within this window (partial writes)
|
|
727
|
+
after_read: str = "move", # inbound: "move" (to processed_subdir) | "delete"
|
|
728
|
+
sort: str = "name", # inbound: process order — "name" | "mtime"
|
|
729
|
+
recursive: bool = False, # inbound: also scan subdirectories
|
|
730
|
+
max_file_bytes: int | None = 16 * 1024 * 1024, # inbound: skip files over this (OOM guard)
|
|
731
|
+
overwrite: bool = False, # outbound: overwrite vs. uniquify a name collision
|
|
732
|
+
processed_subdir: str = ".processed",
|
|
733
|
+
error_subdir: str = ".error",
|
|
734
|
+
) -> ConnectionSpec:
|
|
735
|
+
"""A File endpoint. Inbound polls ``directory`` for ``pattern``; outbound writes ``filename``
|
|
736
|
+
(atomically). ``encoding`` is the file charset (outbound). ``max_file_bytes`` mirrors
|
|
737
|
+
transports.file.DEFAULT_MAX_FILE_BYTES (pass None/0 to disable)."""
|
|
738
|
+
return ConnectionSpec(
|
|
739
|
+
ConnectorType.FILE,
|
|
740
|
+
{
|
|
741
|
+
"directory": directory,
|
|
742
|
+
"filename": filename,
|
|
743
|
+
"pattern": pattern,
|
|
744
|
+
"poll_seconds": poll_seconds,
|
|
745
|
+
"encoding": encoding,
|
|
746
|
+
"min_age_seconds": min_age_seconds,
|
|
747
|
+
"after_read": after_read,
|
|
748
|
+
"sort": sort,
|
|
749
|
+
"recursive": recursive,
|
|
750
|
+
"max_file_bytes": max_file_bytes,
|
|
751
|
+
"overwrite": overwrite,
|
|
752
|
+
"processed_subdir": processed_subdir,
|
|
753
|
+
"error_subdir": error_subdir,
|
|
754
|
+
},
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def Timer(
|
|
759
|
+
*,
|
|
760
|
+
body: str,
|
|
761
|
+
interval_seconds: float | None = None,
|
|
762
|
+
run_once: bool = False,
|
|
763
|
+
encoding: str = "utf-8",
|
|
764
|
+
) -> ConnectionSpec:
|
|
765
|
+
"""A Timer **source** (inbound): emit ``body`` on a schedule (ADR 0011).
|
|
766
|
+
|
|
767
|
+
Set ``interval_seconds`` to fire every N seconds (heartbeat starts at t=0), or ``run_once=True`` to
|
|
768
|
+
fire a single time. ``body`` is emitted verbatim — declare its format with
|
|
769
|
+
``inbound(..., content_type=...)``: the default ``hl7v2`` runs the HL7 peek/validate/ACK path, while
|
|
770
|
+
``text``/``json`` route a :class:`RawMessage` (ADR 0004). In a cluster the schedule is leader-gated,
|
|
771
|
+
so exactly one node fires it (single-node fires as normal). ``cron`` scheduling is a follow-up."""
|
|
772
|
+
return ConnectionSpec(
|
|
773
|
+
ConnectorType.TIMER,
|
|
774
|
+
{
|
|
775
|
+
"body": body,
|
|
776
|
+
"interval_seconds": interval_seconds,
|
|
777
|
+
"run_once": run_once,
|
|
778
|
+
"encoding": encoding,
|
|
779
|
+
},
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def Loopback() -> ConnectionSpec:
|
|
784
|
+
"""A Loopback **inbound** (ADR 0013 Increment 2): an inert inbound with **no source**. Messages
|
|
785
|
+
arrive *only* via the engine-internal ``ingress_handoff`` — a captured reply re-ingressed as a new
|
|
786
|
+
inbound message (a capturing outbound names this inbound with ``reingress_to=...``).
|
|
787
|
+
|
|
788
|
+
It is an ordinary ``inbound(...)`` otherwise: declare its ``router`` (which routes the answer) and
|
|
789
|
+
``content_type`` (``hl7v2`` → :class:`Message`; ``x12``/``text``/``json`` → :class:`RawMessage`). It
|
|
790
|
+
takes **no** ``ack_mode`` (no external peer to ACK — forced to ``NONE``), no ``bind_address``/
|
|
791
|
+
``source_ip_allowlist`` (no socket), and no ``strict`` validation (no untrusted intake)."""
|
|
792
|
+
return ConnectionSpec(ConnectorType.LOOPBACK, {})
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def Rest(
|
|
796
|
+
*,
|
|
797
|
+
url: str | EnvRef, # the endpoint; may be env() for DEV/PROD-specific hosts
|
|
798
|
+
method: str = "POST",
|
|
799
|
+
content_type: str = "application/json",
|
|
800
|
+
headers: dict[str, str] | None = None, # static extra headers (no secrets — not env()-resolved)
|
|
801
|
+
bearer_token: str | EnvRef | None = None, # Authorization: Bearer … (use env() for the secret)
|
|
802
|
+
basic_user: str
|
|
803
|
+
| EnvRef
|
|
804
|
+
| None = None, # HTTP Basic (with basic_password); use env() for secrets
|
|
805
|
+
basic_password: str | EnvRef | None = None,
|
|
806
|
+
timeout_seconds: float = 30.0,
|
|
807
|
+
verify_tls: bool = True, # False (dev only) needs MEFOR_ALLOW_INSECURE_TLS
|
|
808
|
+
encoding: str = "utf-8",
|
|
809
|
+
capture_response: bool = False, # capture the HTTP response body as a reply (ADR 0013)
|
|
810
|
+
reingress_to: str
|
|
811
|
+
| None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
|
|
812
|
+
) -> ConnectionSpec:
|
|
813
|
+
"""An HTTP(S) endpoint (**outbound only** today — there is no REST source yet, ADR 0003). The
|
|
814
|
+
Handler produces the request body; this delivers it to ``url`` via ``method`` with ``content_type``
|
|
815
|
+
+ ``headers`` and optional bearer/basic auth. A 2xx is delivered; 5xx/408/429/connection errors
|
|
816
|
+
retry; other 4xx dead-letter (a permanent rejection). Redirects are refused and the egress host is
|
|
817
|
+
gated by ``[egress].allowed_http``. Put secrets in ``env()`` (``bearer_token``/``basic_*``), never
|
|
818
|
+
in ``headers``. The receiving endpoint **must be idempotent** (delivery is at-least-once)."""
|
|
819
|
+
return ConnectionSpec(
|
|
820
|
+
ConnectorType.REST,
|
|
821
|
+
{
|
|
822
|
+
"url": url,
|
|
823
|
+
"method": method,
|
|
824
|
+
"content_type": content_type,
|
|
825
|
+
"headers": headers or {},
|
|
826
|
+
"bearer_token": bearer_token,
|
|
827
|
+
"basic_user": basic_user,
|
|
828
|
+
"basic_password": basic_password,
|
|
829
|
+
"timeout_seconds": timeout_seconds,
|
|
830
|
+
"verify_tls": verify_tls,
|
|
831
|
+
"encoding": encoding,
|
|
832
|
+
"capture_response": capture_response,
|
|
833
|
+
"reingress_to": reingress_to,
|
|
834
|
+
},
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def Database(
|
|
839
|
+
*,
|
|
840
|
+
server: str | EnvRef, # SQL Server host (may be env())
|
|
841
|
+
database: str | EnvRef,
|
|
842
|
+
statement: str, # parameterized SQL / proc call with :name placeholders
|
|
843
|
+
auth: str = "sql", # sql | integrated | entra
|
|
844
|
+
username: str | EnvRef | None = None,
|
|
845
|
+
password: str | EnvRef | None = None, # secret — use env()
|
|
846
|
+
port: int | EnvRef = 1433,
|
|
847
|
+
encrypt: bool = True, # False (dev only) needs MEFOR_ALLOW_INSECURE_TLS
|
|
848
|
+
trust_server_certificate: bool = False,
|
|
849
|
+
connect_timeout: int = 15,
|
|
850
|
+
app_name: str = "messagefoundry",
|
|
851
|
+
odbc_driver: str = "ODBC Driver 18 for SQL Server",
|
|
852
|
+
pool_max: int = 5,
|
|
853
|
+
acquire_timeout: float = 30.0, # cap a pooled-connection borrow (s) — fail transiently, not forever
|
|
854
|
+
capture_response: bool = False, # capture the statement's RETURNING/OUTPUT result-set (ADR 0013)
|
|
855
|
+
reingress_to: str
|
|
856
|
+
| None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
|
|
857
|
+
capture_max_rows: int = 100, # cap captured rows (over-cap → outcome='unparseable', empty body)
|
|
858
|
+
) -> ConnectionSpec:
|
|
859
|
+
"""A SQL database endpoint (**outbound only** today; SQL Server via the ``[sqlserver]`` extra + ODBC
|
|
860
|
+
Driver 18 — **production / supported**). The Handler produces a JSON-object body; the connector binds its keys
|
|
861
|
+
to the ``:name`` parameters in ``statement`` (translated to positional ``?`` — always parameterized,
|
|
862
|
+
never string-built) and runs it. A transient DB error retries; a constraint/data error (or a payload
|
|
863
|
+
that doesn't match) dead-letters. Put secrets (``password``) in ``env()``. TLS is on by default;
|
|
864
|
+
weakening it needs ``MEFOR_ALLOW_INSECURE_TLS``. The write **must be idempotent** (at-least-once)."""
|
|
865
|
+
return ConnectionSpec(
|
|
866
|
+
ConnectorType.DATABASE,
|
|
867
|
+
{
|
|
868
|
+
"server": server,
|
|
869
|
+
"database": database,
|
|
870
|
+
"statement": statement,
|
|
871
|
+
"auth": auth,
|
|
872
|
+
"username": username,
|
|
873
|
+
"password": password,
|
|
874
|
+
"port": port,
|
|
875
|
+
"encrypt": encrypt,
|
|
876
|
+
"trust_server_certificate": trust_server_certificate,
|
|
877
|
+
"connect_timeout": connect_timeout,
|
|
878
|
+
"app_name": app_name,
|
|
879
|
+
"odbc_driver": odbc_driver,
|
|
880
|
+
"pool_max": pool_max,
|
|
881
|
+
"acquire_timeout": acquire_timeout,
|
|
882
|
+
"capture_response": capture_response,
|
|
883
|
+
"reingress_to": reingress_to,
|
|
884
|
+
"capture_max_rows": capture_max_rows,
|
|
885
|
+
},
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def DatabasePoll(
|
|
890
|
+
*,
|
|
891
|
+
server: str | EnvRef, # SQL Server host (may be env())
|
|
892
|
+
database: str | EnvRef,
|
|
893
|
+
poll_statement: str, # SELECT of the next batch (e.g. WHERE status='NEW' ORDER BY id)
|
|
894
|
+
mark_statement: str
|
|
895
|
+
| None = None, # UPDATE/DELETE run per row after the handler succeeds (:name)
|
|
896
|
+
body_column: str | None = None, # None → whole row as JSON; set → that column's value verbatim
|
|
897
|
+
poll_seconds: float = 5.0,
|
|
898
|
+
auth: str = "sql", # sql | integrated | entra
|
|
899
|
+
username: str | EnvRef | None = None,
|
|
900
|
+
password: str | EnvRef | None = None, # secret — use env()
|
|
901
|
+
port: int | EnvRef = 1433,
|
|
902
|
+
encrypt: bool = True, # False (dev only) needs MEFOR_ALLOW_INSECURE_TLS
|
|
903
|
+
trust_server_certificate: bool = False,
|
|
904
|
+
connect_timeout: int = 15,
|
|
905
|
+
app_name: str = "messagefoundry",
|
|
906
|
+
odbc_driver: str = "ODBC Driver 18 for SQL Server",
|
|
907
|
+
pool_max: int = 5,
|
|
908
|
+
acquire_timeout: float = 30.0, # cap a pooled-connection borrow (s) — fail transiently, not forever
|
|
909
|
+
encoding: str = "utf-8",
|
|
910
|
+
) -> ConnectionSpec:
|
|
911
|
+
"""A SQL database polling **source** (inbound, ADR 0003 §3; SQL Server via the ``[sqlserver]`` extra +
|
|
912
|
+
ODBC Driver 18 — **production / supported**). Every ``poll_seconds`` it runs ``poll_statement`` (a ``SELECT``),
|
|
913
|
+
hands each row to the bound router as a body, then runs ``mark_statement`` (bound from the row's
|
|
914
|
+
columns) so the row isn't re-read — the File source's *process-then-mark-done* shape. At-least-once:
|
|
915
|
+
a crash before the mark re-emits the row, so the downstream pipeline **must tolerate duplicates**.
|
|
916
|
+
|
|
917
|
+
Lead pattern is a status column: ``poll_statement='SELECT id, payload FROM mf_inbox WHERE status=\\'NEW\\''``
|
|
918
|
+
+ ``mark_statement='UPDATE mf_inbox SET status=\\'DONE\\' WHERE id=:id'`` (a ``DELETE`` or a
|
|
919
|
+
high-water-mark ``UPDATE`` work through the same ``mark_statement``). ``body_column`` unset → the
|
|
920
|
+
whole row as a JSON object (pair with ``content_type=json``); set → that one column's value verbatim
|
|
921
|
+
(e.g. a column holding an HL7 message → ``content_type=hl7v2``). Put secrets (``password``) in
|
|
922
|
+
``env()``; TLS is on by default (weakening needs ``MEFOR_ALLOW_INSECURE_TLS``); the polled ``server``
|
|
923
|
+
is gated by ``[egress].allowed_db``."""
|
|
924
|
+
return ConnectionSpec(
|
|
925
|
+
ConnectorType.DATABASE,
|
|
926
|
+
{
|
|
927
|
+
"server": server,
|
|
928
|
+
"database": database,
|
|
929
|
+
"poll_statement": poll_statement,
|
|
930
|
+
"mark_statement": mark_statement,
|
|
931
|
+
"body_column": body_column,
|
|
932
|
+
"poll_seconds": poll_seconds,
|
|
933
|
+
"auth": auth,
|
|
934
|
+
"username": username,
|
|
935
|
+
"password": password,
|
|
936
|
+
"port": port,
|
|
937
|
+
"encrypt": encrypt,
|
|
938
|
+
"trust_server_certificate": trust_server_certificate,
|
|
939
|
+
"connect_timeout": connect_timeout,
|
|
940
|
+
"app_name": app_name,
|
|
941
|
+
"odbc_driver": odbc_driver,
|
|
942
|
+
"pool_max": pool_max,
|
|
943
|
+
"acquire_timeout": acquire_timeout,
|
|
944
|
+
"encoding": encoding,
|
|
945
|
+
},
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def Soap(
|
|
950
|
+
*,
|
|
951
|
+
url: str | EnvRef, # the SOAP endpoint (may be env())
|
|
952
|
+
soap_action: str | EnvRef | None = None, # SOAPAction (1.1 header / 1.2 content-type param)
|
|
953
|
+
soap_version: str = "1.1", # "1.1" | "1.2"
|
|
954
|
+
headers: dict[str, str] | None = None, # static extra headers (no secrets — not env()-resolved)
|
|
955
|
+
bearer_token: str | EnvRef | None = None, # Authorization: Bearer … (use env() for the secret)
|
|
956
|
+
basic_user: str | EnvRef | None = None,
|
|
957
|
+
basic_password: str | EnvRef | None = None,
|
|
958
|
+
timeout_seconds: float = 30.0,
|
|
959
|
+
verify_tls: bool = True, # False (dev only) needs MEFOR_ALLOW_INSECURE_TLS
|
|
960
|
+
encoding: str = "utf-8",
|
|
961
|
+
capture_response: bool = False, # capture the SOAP response envelope as a reply (ADR 0013)
|
|
962
|
+
reingress_to: str
|
|
963
|
+
| None = None, # route the captured reply into this Loopback inbound (implies capture; ADR 0013)
|
|
964
|
+
# --- ADR 0015: mutual TLS + WS-* (Timestamp / UsernameToken / WS-Addressing) ---
|
|
965
|
+
client_cert_file: str
|
|
966
|
+
| EnvRef
|
|
967
|
+
| None = None, # PEM client cert (mTLS); requires client_key_file
|
|
968
|
+
client_key_file: str | EnvRef | None = None, # PEM private key (path or env() text)
|
|
969
|
+
client_key_password: str | EnvRef | None = None, # key passphrase — secret, use env()
|
|
970
|
+
ws_security: bool = False, # stamp <wsse:Security> (Timestamp + optional UsernameToken) in send()
|
|
971
|
+
ws_username: str | EnvRef | None = None, # UsernameToken username (defaults to basic_user)
|
|
972
|
+
ws_password: str | EnvRef | None = None, # UsernameToken password (defaults to basic_password)
|
|
973
|
+
ws_password_type: str = "text", # "text" (PasswordText; recommended over mTLS) | "digest"
|
|
974
|
+
ws_addressing: bool = False, # stamp <wsa:Action/To/MessageID> in send(); requires soap_version 1.2
|
|
975
|
+
ws_timestamp_ttl_seconds: int = 300, # Created→Expires window (must be >= max retry backoff)
|
|
976
|
+
) -> ConnectionSpec:
|
|
977
|
+
"""A SOAP web-service endpoint (**outbound only**, ADR 0003 + 0015).
|
|
978
|
+
|
|
979
|
+
*Plain mode* (default): the Handler produces the **full SOAP envelope** and this POSTs it to ``url``
|
|
980
|
+
with the SOAP ``Content-Type`` (+ a ``SOAPAction`` header for 1.1). *WS-\\* mode* (``ws_addressing``
|
|
981
|
+
/ ``ws_security``, ADR 0015): the Handler produces only the operation **``<Body>`` fragment** and
|
|
982
|
+
the transport wraps it + stamps the non-deterministic ``<wsa:MessageID>`` / ``<wsu:Timestamp>`` /
|
|
983
|
+
optional ``<wsse:UsernameToken>`` headers in ``send()`` (so a pure transform never mints them);
|
|
984
|
+
WS-\\* requires ``soap_version="1.2"``. ``client_cert_file``/``client_key_file`` enable **mutual
|
|
985
|
+
TLS** (incompatible with ``verify_tls=False``).
|
|
986
|
+
|
|
987
|
+
A WS-Security auth/expiry fault, a **Sender/Client** fault, or an unrecognized fault dead-letters;
|
|
988
|
+
a **Receiver/Server** fault retries; otherwise the HTTP status decides. Put secrets in ``env()``
|
|
989
|
+
(``bearer_token``/``basic_*``/``client_key_password``/``ws_password``); the host is gated by
|
|
990
|
+
``[egress].allowed_http`` (shared with REST — **populate it for a PHI mTLS destination**). The
|
|
991
|
+
operation **must be idempotent**: an at-least-once re-send mints a fresh ``<wsa:MessageID>`` (correct
|
|
992
|
+
WS-\\* retry semantics), so the partner's dedup must treat a re-send as a retry, not a duplicate."""
|
|
993
|
+
return ConnectionSpec(
|
|
994
|
+
ConnectorType.SOAP,
|
|
995
|
+
{
|
|
996
|
+
"url": url,
|
|
997
|
+
"soap_action": soap_action,
|
|
998
|
+
"soap_version": soap_version,
|
|
999
|
+
"headers": headers or {},
|
|
1000
|
+
"bearer_token": bearer_token,
|
|
1001
|
+
"basic_user": basic_user,
|
|
1002
|
+
"basic_password": basic_password,
|
|
1003
|
+
"timeout_seconds": timeout_seconds,
|
|
1004
|
+
"verify_tls": verify_tls,
|
|
1005
|
+
"encoding": encoding,
|
|
1006
|
+
"capture_response": capture_response,
|
|
1007
|
+
"reingress_to": reingress_to,
|
|
1008
|
+
"client_cert_file": client_cert_file,
|
|
1009
|
+
"client_key_file": client_key_file,
|
|
1010
|
+
"client_key_password": client_key_password,
|
|
1011
|
+
"ws_security": ws_security,
|
|
1012
|
+
"ws_username": ws_username,
|
|
1013
|
+
"ws_password": ws_password,
|
|
1014
|
+
"ws_password_type": ws_password_type,
|
|
1015
|
+
"ws_addressing": ws_addressing,
|
|
1016
|
+
"ws_timestamp_ttl_seconds": ws_timestamp_ttl_seconds,
|
|
1017
|
+
},
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def Sftp(
|
|
1022
|
+
*,
|
|
1023
|
+
host: str | EnvRef, # the SFTP/SSH server (may be env())
|
|
1024
|
+
port: int | EnvRef = 22,
|
|
1025
|
+
username: str | EnvRef | None = None,
|
|
1026
|
+
password: str | EnvRef | None = None, # secret — use env()
|
|
1027
|
+
private_key: str | EnvRef | None = None, # PEM private key text/path — secret, use env()
|
|
1028
|
+
key_password: str | EnvRef | None = None, # passphrase for an encrypted key — secret, use env()
|
|
1029
|
+
known_hosts: str | EnvRef | None = None, # extra known_hosts file (system hosts always loaded)
|
|
1030
|
+
remote_dir: str | EnvRef,
|
|
1031
|
+
filename: str | EnvRef = "{MSH-10}.hl7", # outbound: upload name (may template HL7 fields)
|
|
1032
|
+
pattern: str = "*.hl7", # inbound: glob of files to poll
|
|
1033
|
+
poll_seconds: float = 5.0, # inbound: poll interval
|
|
1034
|
+
after_read: str = "move", # inbound: "move" (to processed_subdir) | "delete"
|
|
1035
|
+
min_age_seconds: float = 0.0, # inbound: skip files modified within this window (partial writes)
|
|
1036
|
+
max_file_bytes: int | None = 16 * 1024 * 1024, # inbound: skip files over this (OOM guard)
|
|
1037
|
+
overwrite: bool = False, # outbound: overwrite vs. uniquify a name collision
|
|
1038
|
+
processed_subdir: str = ".processed",
|
|
1039
|
+
error_subdir: str = ".error",
|
|
1040
|
+
encoding: str = "utf-8",
|
|
1041
|
+
) -> ConnectionSpec:
|
|
1042
|
+
"""An **SFTP** (SSH file transfer) endpoint — source **and** destination (ADR 0003 follow-on).
|
|
1043
|
+
|
|
1044
|
+
Inbound polls ``remote_dir`` for ``pattern`` (process-then-move/delete, at-least-once); outbound
|
|
1045
|
+
uploads to ``remote_dir``/``filename`` (write to a temp name then rename, so a poller never sees a
|
|
1046
|
+
partial). Needs the ``[sftp]`` extra (``pip install 'messagefoundry[sftp]'``; paramiko is lazily
|
|
1047
|
+
imported). **Host-key verification is ON by default** (system + ``known_hosts``; an unknown key is
|
|
1048
|
+
refused) — accepting an unknown key needs ``MEFOR_ALLOW_INSECURE_TLS``. Put secrets (``password``/
|
|
1049
|
+
``private_key``/``key_password``) in ``env()``. The host is gated by ``[egress].allowed_remote``
|
|
1050
|
+
(both directions). At-least-once: an upload may re-send and a poll may re-emit, so downstreams
|
|
1051
|
+
**must be idempotent**."""
|
|
1052
|
+
return ConnectionSpec(
|
|
1053
|
+
ConnectorType.REMOTEFILE,
|
|
1054
|
+
{
|
|
1055
|
+
"protocol": "sftp",
|
|
1056
|
+
"host": host,
|
|
1057
|
+
"port": port,
|
|
1058
|
+
"username": username,
|
|
1059
|
+
"password": password,
|
|
1060
|
+
"private_key": private_key,
|
|
1061
|
+
"key_password": key_password,
|
|
1062
|
+
"known_hosts": known_hosts,
|
|
1063
|
+
"remote_dir": remote_dir,
|
|
1064
|
+
"filename": filename,
|
|
1065
|
+
"pattern": pattern,
|
|
1066
|
+
"poll_seconds": poll_seconds,
|
|
1067
|
+
"after_read": after_read,
|
|
1068
|
+
"min_age_seconds": min_age_seconds,
|
|
1069
|
+
"max_file_bytes": max_file_bytes,
|
|
1070
|
+
"overwrite": overwrite,
|
|
1071
|
+
"processed_subdir": processed_subdir,
|
|
1072
|
+
"error_subdir": error_subdir,
|
|
1073
|
+
"encoding": encoding,
|
|
1074
|
+
},
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def Ftp(
|
|
1079
|
+
*,
|
|
1080
|
+
host: str | EnvRef, # the FTP server (may be env())
|
|
1081
|
+
port: int | EnvRef = 21,
|
|
1082
|
+
tls: bool = False, # True → FTPS (explicit TLS, PROT P); False → plain ftp
|
|
1083
|
+
username: str | EnvRef | None = None,
|
|
1084
|
+
password: str | EnvRef | None = None, # secret — use env()
|
|
1085
|
+
remote_dir: str | EnvRef,
|
|
1086
|
+
filename: str | EnvRef = "{MSH-10}.hl7", # outbound: upload name (may template HL7 fields)
|
|
1087
|
+
pattern: str = "*.hl7", # inbound: glob of files to poll
|
|
1088
|
+
poll_seconds: float = 5.0, # inbound: poll interval
|
|
1089
|
+
after_read: str = "move", # inbound: "move" (to processed_subdir) | "delete"
|
|
1090
|
+
min_age_seconds: float = 0.0, # inbound: skip files modified within this window (partial writes)
|
|
1091
|
+
max_file_bytes: int | None = 16 * 1024 * 1024, # inbound: skip files over this (OOM guard)
|
|
1092
|
+
overwrite: bool = False, # outbound: overwrite vs. uniquify a name collision
|
|
1093
|
+
processed_subdir: str = ".processed",
|
|
1094
|
+
error_subdir: str = ".error",
|
|
1095
|
+
encoding: str = "utf-8",
|
|
1096
|
+
) -> ConnectionSpec:
|
|
1097
|
+
"""An **FTP** (``tls=False``) or **FTPS** (``tls=True`` — explicit TLS) endpoint, source **and**
|
|
1098
|
+
destination (stdlib ``ftplib`` — no extra). Same poll/upload shape as :func:`Sftp`.
|
|
1099
|
+
|
|
1100
|
+
Plain ``ftp`` transmits credentials in **cleartext**: supplying a ``username``/``password`` over
|
|
1101
|
+
plain ``ftp`` is **refused** unless ``MEFOR_ALLOW_INSECURE_TLS`` is set (use ``tls=True`` for FTPS,
|
|
1102
|
+
or :func:`Sftp`). FTPS encrypts the control + data channels, so credentials are fine there. Put
|
|
1103
|
+
secrets (``password``) in ``env()``. The host is gated by ``[egress].allowed_remote`` (both
|
|
1104
|
+
directions). At-least-once → downstreams **must be idempotent**."""
|
|
1105
|
+
return ConnectionSpec(
|
|
1106
|
+
ConnectorType.REMOTEFILE,
|
|
1107
|
+
{
|
|
1108
|
+
"protocol": "ftps" if tls else "ftp",
|
|
1109
|
+
"host": host,
|
|
1110
|
+
"port": port,
|
|
1111
|
+
"username": username,
|
|
1112
|
+
"password": password,
|
|
1113
|
+
"remote_dir": remote_dir,
|
|
1114
|
+
"filename": filename,
|
|
1115
|
+
"pattern": pattern,
|
|
1116
|
+
"poll_seconds": poll_seconds,
|
|
1117
|
+
"after_read": after_read,
|
|
1118
|
+
"min_age_seconds": min_age_seconds,
|
|
1119
|
+
"max_file_bytes": max_file_bytes,
|
|
1120
|
+
"overwrite": overwrite,
|
|
1121
|
+
"processed_subdir": processed_subdir,
|
|
1122
|
+
"error_subdir": error_subdir,
|
|
1123
|
+
"encoding": encoding,
|
|
1124
|
+
},
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
@dataclass(frozen=True)
|
|
1129
|
+
class Send:
|
|
1130
|
+
"""A Handler's instruction to deliver ``message`` to a named outbound connection."""
|
|
1131
|
+
|
|
1132
|
+
to: str
|
|
1133
|
+
message: Message | RawMessage | str
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
#: JSON-serializable scalar/container types a :class:`SetState` value may carry. Validated at
|
|
1137
|
+
#: construction (fail loud in the author's code, not deep in a store INSERT), and what
|
|
1138
|
+
#: :func:`messagefoundry.config.state.state_get` returns on a hit.
|
|
1139
|
+
StateValue = str | int | float | bool | None | list[Any] | dict[str, Any]
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
@dataclass(frozen=True)
|
|
1143
|
+
class SetState:
|
|
1144
|
+
"""A Handler's instruction to **declare** a state write (cross-message correlation, ADR 0005).
|
|
1145
|
+
|
|
1146
|
+
A Handler does not mutate state imperatively; it returns ``SetState(namespace, key, value)``
|
|
1147
|
+
alongside its :class:`Send`\\ s, and the engine applies the upsert **inside the routed→outbound
|
|
1148
|
+
handoff transaction** — so a crash before commit leaves no state and a re-run applies it exactly
|
|
1149
|
+
once (preserving the staged pipeline's pure-re-run invariant). ``value`` must be JSON-serializable
|
|
1150
|
+
(validated here); read it back synchronously with
|
|
1151
|
+
:func:`messagefoundry.config.state.state_get`."""
|
|
1152
|
+
|
|
1153
|
+
namespace: str
|
|
1154
|
+
key: str
|
|
1155
|
+
value: StateValue
|
|
1156
|
+
|
|
1157
|
+
def __post_init__(self) -> None:
|
|
1158
|
+
# Validate at construction so a non-serializable value fails in the author's handler (with a
|
|
1159
|
+
# clear message) rather than deep inside the store's INSERT during a handoff. namespace/key
|
|
1160
|
+
# are the composite PK and must be non-empty strings.
|
|
1161
|
+
if not isinstance(self.namespace, str) or not self.namespace:
|
|
1162
|
+
raise WiringError("SetState namespace must be a non-empty string")
|
|
1163
|
+
if not isinstance(self.key, str) or not self.key:
|
|
1164
|
+
raise WiringError("SetState key must be a non-empty string")
|
|
1165
|
+
try:
|
|
1166
|
+
import json
|
|
1167
|
+
|
|
1168
|
+
json.dumps(self.value)
|
|
1169
|
+
except (TypeError, ValueError) as exc:
|
|
1170
|
+
raise WiringError(
|
|
1171
|
+
f"SetState({self.namespace!r}, {self.key!r}, ...): value must be JSON-serializable "
|
|
1172
|
+
f"(str/int/float/bool/None/list/dict) — {exc}"
|
|
1173
|
+
) from exc
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
#: What a Router/Handler receives: a mutable HL7 :class:`Message`, or a :class:`RawMessage` for a
|
|
1177
|
+
#: non-HL7 inbound (ADR 0004). The author knows which — a Router/Handler is bound to one inbound.
|
|
1178
|
+
Payload = Message | RawMessage
|
|
1179
|
+
RouterFn = Callable[[Payload], "list[str] | str | None"]
|
|
1180
|
+
#: A Handler returns deliveries and/or state writes (ADR 0005): a single :class:`Send`/:class:`SetState`,
|
|
1181
|
+
#: a mixed list, or ``None`` (filtered). ``Send``-only returns are unchanged — backward compatible.
|
|
1182
|
+
HandlerFn = Callable[[Payload], "Send | SetState | list[Send | SetState] | None"]
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
@dataclass(frozen=True)
|
|
1186
|
+
class InboundConnection:
|
|
1187
|
+
name: str
|
|
1188
|
+
spec: ConnectionSpec
|
|
1189
|
+
router: str
|
|
1190
|
+
ack_mode: AckMode = AckMode.ORIGINAL
|
|
1191
|
+
# None = inherit the global [inbound] ack_after default; an explicit value overrides it. Resolved
|
|
1192
|
+
# in the RegistryRunner, which rejects 'delivered' until that path is implemented (ADR 0001).
|
|
1193
|
+
ack_after: AckAfter | None = None
|
|
1194
|
+
validation: Validation = field(default_factory=Validation)
|
|
1195
|
+
content_type: ContentType = ContentType.HL7V2 # payload format (ADR 0004); HL7V2 = the HL7 path
|
|
1196
|
+
# Operability (Tier 4): free-form operator metadata (owner/runbook/env labels — surfaced by the
|
|
1197
|
+
# API, never used for routing); a per-connection inbound bind interface that overrides the service
|
|
1198
|
+
# [inbound].bind_host; and an inbound peer-IP allowlist (MLLP/TCP listen sources only). All
|
|
1199
|
+
# default to None/absent = unchanged behaviour.
|
|
1200
|
+
metadata: Mapping[str, Any] | None = None
|
|
1201
|
+
bind_address: str | None = None
|
|
1202
|
+
source_ip_allowlist: tuple[str, ...] | None = None
|
|
1203
|
+
source_file: str | None = None # where it was declared (for IDE go-to-definition)
|
|
1204
|
+
source_line: int | None = None
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
@dataclass(frozen=True)
|
|
1208
|
+
class OutboundConnection:
|
|
1209
|
+
name: str
|
|
1210
|
+
spec: ConnectionSpec
|
|
1211
|
+
# None = inherit the global [delivery] default; an explicit value overrides it. Resolution
|
|
1212
|
+
# (per-connection override > [delivery] global default > built-in) happens in the RegistryRunner.
|
|
1213
|
+
retry: RetryPolicy | None = None
|
|
1214
|
+
ordering: OrderingMode | None = None
|
|
1215
|
+
internal_error: InternalErrorPolicy | None = None
|
|
1216
|
+
buildup: BuildupThreshold | None = None
|
|
1217
|
+
# Shadow / parallel-run egress suppression (#15). False = deliver normally; True = the delivery
|
|
1218
|
+
# worker suppresses the real egress + finalizes PROCESSED. [shadow].simulate_all_egress forces it on.
|
|
1219
|
+
simulate: bool = False
|
|
1220
|
+
metadata: Mapping[str, Any] | None = (
|
|
1221
|
+
None # operability labels (Tier 4); API-surfaced, not routing
|
|
1222
|
+
)
|
|
1223
|
+
source_file: str | None = None
|
|
1224
|
+
source_line: int | None = None
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
@dataclass
|
|
1228
|
+
class Registry:
|
|
1229
|
+
"""The wired graph produced by loading config modules."""
|
|
1230
|
+
|
|
1231
|
+
inbound: dict[str, InboundConnection] = field(default_factory=dict)
|
|
1232
|
+
outbound: dict[str, OutboundConnection] = field(default_factory=dict)
|
|
1233
|
+
routers: dict[str, RouterFn] = field(default_factory=dict)
|
|
1234
|
+
handlers: dict[str, HandlerFn] = field(default_factory=dict)
|
|
1235
|
+
# Reference lookup tables loaded from <config_dir>/codesets/ — attached so a runner can re-publish
|
|
1236
|
+
# this graph's code sets as the active set while its routers/handlers run (call-time resolution).
|
|
1237
|
+
code_sets: dict[str, CodeSet] = field(default_factory=dict)
|
|
1238
|
+
# Reference-set declarations (ADR 0006): name -> source + cadence. The engine's ReferenceSyncRunner
|
|
1239
|
+
# materializes each into a store snapshot; reference(name) reads the snapshot (data lives in the
|
|
1240
|
+
# store, not here). Carried with the graph so a reload re-arms the sync set atomically.
|
|
1241
|
+
references: dict[str, ReferenceSpec] = field(default_factory=dict)
|
|
1242
|
+
# Live-lookup connection declarations (ADR 0010): name -> connection settings. The RegistryRunner
|
|
1243
|
+
# builds one pooled executor from these; db_lookup(name, ...) queries it at handler run time. Carried
|
|
1244
|
+
# with the graph so a reload re-arms the executor atomically.
|
|
1245
|
+
lookups: dict[str, DatabaseLookupSpec] = field(default_factory=dict)
|
|
1246
|
+
|
|
1247
|
+
def add_inbound(self, conn: InboundConnection) -> None:
|
|
1248
|
+
self._add(self.inbound, conn.name, conn, "inbound connection")
|
|
1249
|
+
|
|
1250
|
+
def add_outbound(self, conn: OutboundConnection) -> None:
|
|
1251
|
+
self._add(self.outbound, conn.name, conn, "outbound connection")
|
|
1252
|
+
|
|
1253
|
+
def add_router(self, name: str, fn: RouterFn) -> None:
|
|
1254
|
+
self._add(self.routers, name, fn, "router")
|
|
1255
|
+
|
|
1256
|
+
def add_handler(self, name: str, fn: HandlerFn) -> None:
|
|
1257
|
+
self._add(self.handlers, name, fn, "handler")
|
|
1258
|
+
|
|
1259
|
+
def add_reference(self, spec: ReferenceSpec) -> None:
|
|
1260
|
+
self._add(self.references, spec.name, spec, "reference set")
|
|
1261
|
+
|
|
1262
|
+
def add_lookup(self, spec: DatabaseLookupSpec) -> None:
|
|
1263
|
+
self._add(self.lookups, spec.name, spec, "database lookup")
|
|
1264
|
+
|
|
1265
|
+
@staticmethod
|
|
1266
|
+
def _add(table: dict[str, Any], name: str, value: Any, kind: str) -> None:
|
|
1267
|
+
if name in table:
|
|
1268
|
+
raise WiringError(f"duplicate {kind} name: {name!r}")
|
|
1269
|
+
table[name] = value
|
|
1270
|
+
|
|
1271
|
+
def validate(self) -> None:
|
|
1272
|
+
"""Statically check references (inbound → router) and literal inbound port collisions."""
|
|
1273
|
+
for conn in self.inbound.values():
|
|
1274
|
+
if conn.router not in self.routers:
|
|
1275
|
+
raise WiringError(
|
|
1276
|
+
f"inbound connection {conn.name!r} references unknown router {conn.router!r}"
|
|
1277
|
+
)
|
|
1278
|
+
collisions = self.port_collisions()
|
|
1279
|
+
if collisions:
|
|
1280
|
+
port, first, second = collisions[0]
|
|
1281
|
+
raise WiringError(f"inbound connections {first!r} and {second!r} both bind port {port}")
|
|
1282
|
+
|
|
1283
|
+
def port_collisions(self) -> list[tuple[int, str, str]]:
|
|
1284
|
+
"""Inbound connections sharing a literal bind port, as ``(port, first, colliding)`` tuples.
|
|
1285
|
+
|
|
1286
|
+
Caught statically so a duplicate port surfaces at validate/``check`` time naming both
|
|
1287
|
+
connections, instead of aborting the whole engine with a bare bind ``OSError`` (review
|
|
1288
|
+
low-13). ``EnvRef`` ports resolve per-environment, so only ``int`` literals are checkable."""
|
|
1289
|
+
seen: dict[int, str] = {}
|
|
1290
|
+
out: list[tuple[int, str, str]] = []
|
|
1291
|
+
for conn in self.inbound.values():
|
|
1292
|
+
port = conn.spec.settings.get("port")
|
|
1293
|
+
if isinstance(port, int) and not isinstance(port, bool):
|
|
1294
|
+
if port in seen:
|
|
1295
|
+
out.append((port, seen[port], conn.name))
|
|
1296
|
+
else:
|
|
1297
|
+
seen[port] = conn.name
|
|
1298
|
+
return out
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
# --- declaration API (writes to the registry being loaded) -------------------
|
|
1302
|
+
|
|
1303
|
+
_active: Registry | None = None
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _active_registry() -> Registry:
|
|
1307
|
+
if _active is None:
|
|
1308
|
+
raise WiringError(
|
|
1309
|
+
"inbound/outbound/router/handler must be declared in a config module loaded "
|
|
1310
|
+
"via load_config()"
|
|
1311
|
+
)
|
|
1312
|
+
return _active
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _call_site() -> tuple[str | None, int | None]:
|
|
1316
|
+
"""File + line of the config module that called the declaration (for IDE go-to-definition)."""
|
|
1317
|
+
caller = sys._getframe(2) # _call_site -> inbound/outbound -> config module
|
|
1318
|
+
return caller.f_code.co_filename, caller.f_lineno
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _check_metadata(name: str, metadata: Mapping[str, Any] | None) -> None:
|
|
1322
|
+
"""Operability metadata must be a key/value table (or absent) — operator labels, not config."""
|
|
1323
|
+
if metadata is not None and not isinstance(metadata, Mapping):
|
|
1324
|
+
raise WiringError(f"connection {name!r}: metadata must be a table (key/value mapping)")
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _check_source_ip_allowlist(
|
|
1328
|
+
name: str, listens: bool, allowlist: list[str] | None
|
|
1329
|
+
) -> tuple[str, ...] | None:
|
|
1330
|
+
"""Validate an inbound peer-IP allowlist and freeze it to a tuple. Each entry must parse as an IP
|
|
1331
|
+
address or a CIDR network; the allowlist is only meaningful for an MLLP/TCP **listen** source.
|
|
1332
|
+
``None``/empty = no restriction (the ``[egress]`` allowlist convention)."""
|
|
1333
|
+
if not allowlist:
|
|
1334
|
+
return None
|
|
1335
|
+
if not listens:
|
|
1336
|
+
raise WiringError(
|
|
1337
|
+
f"inbound connection {name!r}: source_ip_allowlist is only valid for an MLLP/TCP "
|
|
1338
|
+
"listen source"
|
|
1339
|
+
)
|
|
1340
|
+
for entry in allowlist:
|
|
1341
|
+
if not isinstance(entry, str) or not entry.strip():
|
|
1342
|
+
raise WiringError(
|
|
1343
|
+
f"inbound connection {name!r}: source_ip_allowlist entries must be non-empty strings"
|
|
1344
|
+
)
|
|
1345
|
+
try:
|
|
1346
|
+
if "/" in entry:
|
|
1347
|
+
ipaddress.ip_network(entry, strict=False)
|
|
1348
|
+
else:
|
|
1349
|
+
ipaddress.ip_address(entry)
|
|
1350
|
+
except ValueError as exc:
|
|
1351
|
+
raise WiringError(
|
|
1352
|
+
f"inbound connection {name!r}: source_ip_allowlist entry {entry!r} is not a valid "
|
|
1353
|
+
f"IP address or CIDR network ({exc})"
|
|
1354
|
+
) from exc
|
|
1355
|
+
return tuple(allowlist)
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _coerce_content_type(name: str, content_type: ContentType | str) -> ContentType:
|
|
1359
|
+
"""Coerce a ``content_type`` argument to the :class:`ContentType` enum (or fail loud).
|
|
1360
|
+
|
|
1361
|
+
A code-first author may pass the bare string (``content_type="x12"``) rather than the enum member;
|
|
1362
|
+
coerce it here, at the one shared inbound boundary, so a raw string can't flow into the pipeline and
|
|
1363
|
+
blow up later as ``'str' object has no attribute 'value'`` deep in dry-run. An unrecognized value
|
|
1364
|
+
fails loud as a :class:`WiringError` naming the connection and the allowed values — the same loud
|
|
1365
|
+
failure the ``connections.toml`` loader already gives. A member passed in is returned unchanged."""
|
|
1366
|
+
if isinstance(content_type, ContentType):
|
|
1367
|
+
return content_type
|
|
1368
|
+
try:
|
|
1369
|
+
return ContentType(content_type)
|
|
1370
|
+
except ValueError as exc:
|
|
1371
|
+
allowed = ", ".join(repr(member.value) for member in ContentType)
|
|
1372
|
+
raise WiringError(
|
|
1373
|
+
f"inbound connection {name!r}: invalid content_type {content_type!r} (allowed: {allowed})"
|
|
1374
|
+
) from exc
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def build_inbound_connection(
|
|
1378
|
+
name: str,
|
|
1379
|
+
spec: ConnectionSpec,
|
|
1380
|
+
*,
|
|
1381
|
+
router: str,
|
|
1382
|
+
ack_mode: AckMode = AckMode.ORIGINAL,
|
|
1383
|
+
ack_after: AckAfter | None = None,
|
|
1384
|
+
strict: bool = False,
|
|
1385
|
+
hl7_version: str | None = None,
|
|
1386
|
+
content_type: ContentType | str = ContentType.HL7V2,
|
|
1387
|
+
metadata: Mapping[str, Any] | None = None,
|
|
1388
|
+
bind_address: str | None = None,
|
|
1389
|
+
source_ip_allowlist: list[str] | None = None,
|
|
1390
|
+
source_file: str | None = None,
|
|
1391
|
+
source_line: int | None = None,
|
|
1392
|
+
) -> InboundConnection:
|
|
1393
|
+
"""Validate the inbound-connection invariants and build an :class:`InboundConnection`.
|
|
1394
|
+
|
|
1395
|
+
The shared core of code-first :func:`inbound` **and** the ``connections.toml`` loader (ADR 0007),
|
|
1396
|
+
so both authoring surfaces enforce identical guards. Pure — it does not touch the active registry;
|
|
1397
|
+
the caller is responsible for ``add_inbound``. ``content_type`` accepts a :class:`ContentType`
|
|
1398
|
+
member **or** its bare string value (``"x12"``, ``"json"``, …); it is coerced to the enum here so a
|
|
1399
|
+
raw string can't reach the pipeline and crash later."""
|
|
1400
|
+
content_type = _coerce_content_type(name, content_type)
|
|
1401
|
+
if (
|
|
1402
|
+
spec.type in (ConnectorType.MLLP, ConnectorType.TCP, ConnectorType.X12)
|
|
1403
|
+
and spec.settings.get("host") is not None
|
|
1404
|
+
):
|
|
1405
|
+
# The bind interface is an environment/service decision (which NIC this instance exposes),
|
|
1406
|
+
# not a per-connection one — and exposing an unauthenticated raw listener on 0.0.0.0 must be
|
|
1407
|
+
# an admin choice, not a developer default. Set it service-side via [inbound].bind_host.
|
|
1408
|
+
kind = spec.type.value.upper()
|
|
1409
|
+
raise WiringError(
|
|
1410
|
+
f"inbound connection {name!r}: {kind} inbound takes no host; the bind interface is a "
|
|
1411
|
+
f"service setting ([inbound].bind_host). Declare it as {kind.title()}(port=...)."
|
|
1412
|
+
)
|
|
1413
|
+
if ack_after == AckAfter.DELIVERED:
|
|
1414
|
+
# Deferred-until-delivered ACK needs the listener to hold/replay the ACK from the delivery
|
|
1415
|
+
# worker (sender socket details, held connection) — not built in Step A. Fail loud at wiring
|
|
1416
|
+
# so a config asking for it is caught in dry-run / `messagefoundry check`, not silently
|
|
1417
|
+
# downgraded. (This also rules out the incoherent DELIVERED + ack_mode=NONE combination.)
|
|
1418
|
+
# Compared by VALUE not identity: AckAfter is a str-Enum, so a raw-string ack_after='delivered'
|
|
1419
|
+
# (== the member but not `is` it) must still be caught rather than slipping through as INGEST.
|
|
1420
|
+
raise WiringError(
|
|
1421
|
+
f"inbound connection {name!r}: ack_after='delivered' is not yet implemented "
|
|
1422
|
+
"(Step A ships ACK-on-receipt only — use ack_after='ingest', the default)"
|
|
1423
|
+
)
|
|
1424
|
+
if content_type is not ContentType.HL7V2 and strict:
|
|
1425
|
+
# Strict validation is hl7apy structure/cardinality validation — meaningless for a JSON/XML/text
|
|
1426
|
+
# body. Fail loud at wiring (caught in dry-run / `messagefoundry check`) rather than silently
|
|
1427
|
+
# ignoring it; non-HL7 payloads are validated in the Handler instead (ADR 0004).
|
|
1428
|
+
raise WiringError(
|
|
1429
|
+
f"inbound connection {name!r}: validation.strict is HL7-specific and can't apply to a "
|
|
1430
|
+
f"{content_type.value!r} content_type — validate non-HL7 payloads in the Handler instead"
|
|
1431
|
+
)
|
|
1432
|
+
if spec.type is ConnectorType.LOOPBACK:
|
|
1433
|
+
# A loopback inbound (ADR 0013) has no socket and no untrusted intake: strict HL7 validation is
|
|
1434
|
+
# meaningless, and there is no external peer to ACK. Messages arrive only via ingress_handoff.
|
|
1435
|
+
if strict:
|
|
1436
|
+
raise WiringError(
|
|
1437
|
+
f"inbound connection {name!r}: validation.strict is meaningless for a Loopback() "
|
|
1438
|
+
"inbound (no socket / no untrusted intake)"
|
|
1439
|
+
)
|
|
1440
|
+
if ack_mode in (AckMode.NONE, AckMode.ORIGINAL):
|
|
1441
|
+
ack_mode = AckMode.NONE # unset/default → NONE (no external peer to ACK)
|
|
1442
|
+
else:
|
|
1443
|
+
raise WiringError(
|
|
1444
|
+
f"inbound connection {name!r}: Loopback() takes no ACK (no external peer) — "
|
|
1445
|
+
"ack_mode must be NONE"
|
|
1446
|
+
)
|
|
1447
|
+
_check_metadata(name, metadata)
|
|
1448
|
+
listens = spec.type in (ConnectorType.MLLP, ConnectorType.TCP)
|
|
1449
|
+
if bind_address is not None:
|
|
1450
|
+
if not listens:
|
|
1451
|
+
# Only a listen source (MLLP/TCP) binds an interface; File/DB/etc. have nothing to bind.
|
|
1452
|
+
raise WiringError(
|
|
1453
|
+
f"inbound connection {name!r}: bind_address is only valid for an MLLP/TCP "
|
|
1454
|
+
"listen source"
|
|
1455
|
+
)
|
|
1456
|
+
if not bind_address.strip():
|
|
1457
|
+
# A present-but-blank bind_address would crash asyncio.start_server at boot (getaddrinfo
|
|
1458
|
+
# fails on whitespace) — fail loud at wiring so it's caught in dry-run / `messagefoundry
|
|
1459
|
+
# check`, like the allowlist. (Omit bind_address to inherit [inbound].bind_host.)
|
|
1460
|
+
raise WiringError(
|
|
1461
|
+
f"inbound connection {name!r}: bind_address must be a non-empty host/IP, not blank"
|
|
1462
|
+
)
|
|
1463
|
+
allowlist = _check_source_ip_allowlist(name, listens, source_ip_allowlist)
|
|
1464
|
+
return InboundConnection(
|
|
1465
|
+
name=name,
|
|
1466
|
+
spec=spec,
|
|
1467
|
+
router=router,
|
|
1468
|
+
ack_mode=ack_mode,
|
|
1469
|
+
ack_after=ack_after,
|
|
1470
|
+
validation=Validation(strict=strict, hl7_version=hl7_version),
|
|
1471
|
+
content_type=content_type,
|
|
1472
|
+
metadata=metadata,
|
|
1473
|
+
bind_address=bind_address,
|
|
1474
|
+
source_ip_allowlist=allowlist,
|
|
1475
|
+
source_file=source_file,
|
|
1476
|
+
source_line=source_line,
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def inbound(
|
|
1481
|
+
name: str,
|
|
1482
|
+
spec: ConnectionSpec,
|
|
1483
|
+
*,
|
|
1484
|
+
router: str,
|
|
1485
|
+
ack_mode: AckMode = AckMode.ORIGINAL,
|
|
1486
|
+
ack_after: AckAfter | None = None,
|
|
1487
|
+
strict: bool = False,
|
|
1488
|
+
hl7_version: str | None = None,
|
|
1489
|
+
content_type: ContentType | str = ContentType.HL7V2,
|
|
1490
|
+
metadata: Mapping[str, Any] | None = None,
|
|
1491
|
+
bind_address: str | None = None,
|
|
1492
|
+
source_ip_allowlist: list[str] | None = None,
|
|
1493
|
+
) -> None:
|
|
1494
|
+
"""Declare an inbound connection that feeds every received message to ``router``.
|
|
1495
|
+
|
|
1496
|
+
``ack_after`` selects ACK *timing* (staged pipeline, ADR 0001): the default ``INGEST``
|
|
1497
|
+
(ACK-on-receipt) is the only value supported in Step A — ``DELIVERED`` (defer the ACK until
|
|
1498
|
+
delivery) is not yet implemented and raises ``WiringError``. ``ack_after`` is distinct from
|
|
1499
|
+
``ack_mode`` (the ACK code family).
|
|
1500
|
+
|
|
1501
|
+
``content_type`` (ADR 0004) selects the payload format: the default ``HL7V2`` runs the HL7
|
|
1502
|
+
peek/validate/ACK path and the Router/Handler receive a :class:`Message`; any other value skips HL7
|
|
1503
|
+
parsing and they receive a :class:`RawMessage` (``.raw``/``.text``/``.json()``). It may be a
|
|
1504
|
+
:class:`ContentType` member **or** its bare string value (``content_type="x12"``), coerced at load —
|
|
1505
|
+
an unrecognized string fails loud as a :class:`WiringError`. ``strict`` validation is HL7-only, so it
|
|
1506
|
+
cannot combine with a non-HL7 ``content_type``.
|
|
1507
|
+
|
|
1508
|
+
Operability (Tier 4, all optional): ``metadata`` attaches free-form operator labels
|
|
1509
|
+
(owner/runbook/environment) surfaced by the API and never used for routing; ``bind_address``
|
|
1510
|
+
overrides the service ``[inbound].bind_host`` for this MLLP/TCP listener only; ``source_ip_allowlist``
|
|
1511
|
+
restricts an MLLP/TCP listener to the given peer IPs / CIDR networks (absent/empty = no restriction)."""
|
|
1512
|
+
file, line = _call_site()
|
|
1513
|
+
_active_registry().add_inbound(
|
|
1514
|
+
build_inbound_connection(
|
|
1515
|
+
name,
|
|
1516
|
+
spec,
|
|
1517
|
+
router=router,
|
|
1518
|
+
ack_mode=ack_mode,
|
|
1519
|
+
ack_after=ack_after,
|
|
1520
|
+
strict=strict,
|
|
1521
|
+
hl7_version=hl7_version,
|
|
1522
|
+
content_type=content_type,
|
|
1523
|
+
metadata=metadata,
|
|
1524
|
+
bind_address=bind_address,
|
|
1525
|
+
source_ip_allowlist=source_ip_allowlist,
|
|
1526
|
+
source_file=file,
|
|
1527
|
+
source_line=line,
|
|
1528
|
+
)
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def build_outbound_connection(
|
|
1533
|
+
name: str,
|
|
1534
|
+
spec: ConnectionSpec,
|
|
1535
|
+
*,
|
|
1536
|
+
retry: RetryPolicy | None = None,
|
|
1537
|
+
ordering: OrderingMode | None = None,
|
|
1538
|
+
internal_error: InternalErrorPolicy | None = None,
|
|
1539
|
+
buildup: BuildupThreshold | None = None,
|
|
1540
|
+
simulate: bool = False,
|
|
1541
|
+
metadata: Mapping[str, Any] | None = None,
|
|
1542
|
+
source_file: str | None = None,
|
|
1543
|
+
source_line: int | None = None,
|
|
1544
|
+
) -> OutboundConnection:
|
|
1545
|
+
"""Validate the outbound-connection invariants and build an :class:`OutboundConnection`.
|
|
1546
|
+
|
|
1547
|
+
The shared core of code-first :func:`outbound` **and** the ``connections.toml`` loader (ADR 0007).
|
|
1548
|
+
Pure — it does not touch the active registry; the caller is responsible for ``add_outbound``."""
|
|
1549
|
+
if (
|
|
1550
|
+
spec.type in (ConnectorType.MLLP, ConnectorType.TCP, ConnectorType.X12)
|
|
1551
|
+
and spec.settings.get("host") is None
|
|
1552
|
+
):
|
|
1553
|
+
# Outbound MLLP/TCP/X12 dials a downstream peer, so a host is mandatory. (It's the value that
|
|
1554
|
+
# legitimately differs per environment — see env() for DEV/PROD-specific peers.)
|
|
1555
|
+
kind = spec.type.value.upper()
|
|
1556
|
+
raise WiringError(
|
|
1557
|
+
f"outbound connection {name!r}: {kind} outbound requires a host (the downstream peer), "
|
|
1558
|
+
f"e.g. {kind.title()}(host=..., port=...)."
|
|
1559
|
+
)
|
|
1560
|
+
_check_metadata(name, metadata)
|
|
1561
|
+
# ADR 0013 Increment 2: reingress_to (route this outbound's reply back as a new inbound message)
|
|
1562
|
+
# IMPLIES capture (the reply must be captured to re-ingress it). Force capture_response here so the
|
|
1563
|
+
# capture-validity guards below also gate a re-ingress declaration; the cross-registry check that
|
|
1564
|
+
# reingress_to names an existing Loopback() inbound runs in build_check_registry (it sees the whole
|
|
1565
|
+
# registry). A re-ingress on FILE/REMOTEFILE therefore fails with the "no synchronous response" error.
|
|
1566
|
+
reingress_to = spec.settings.get("reingress_to")
|
|
1567
|
+
if reingress_to is not None:
|
|
1568
|
+
if not isinstance(reingress_to, str) or not reingress_to.strip():
|
|
1569
|
+
raise WiringError(
|
|
1570
|
+
f"outbound connection {name!r}: reingress_to must be a non-empty inbound name (ADR 0013)"
|
|
1571
|
+
)
|
|
1572
|
+
spec.settings["capture_response"] = True
|
|
1573
|
+
# ADR 0013: response capture must be wiring-valid at `check`/dry-run time (no store needed), and
|
|
1574
|
+
# this is the choke point for BOTH the code-first factories and the connections.toml desugar.
|
|
1575
|
+
if spec.settings.get("capture_response"):
|
|
1576
|
+
if spec.type in (ConnectorType.FILE, ConnectorType.REMOTEFILE):
|
|
1577
|
+
raise WiringError(
|
|
1578
|
+
f"outbound connection {name!r}: {spec.type.value.upper()} has no synchronous response, "
|
|
1579
|
+
"so capture_response=True is invalid (ADR 0013)."
|
|
1580
|
+
)
|
|
1581
|
+
if spec.type is ConnectorType.TCP and not spec.settings.get("expect_reply"):
|
|
1582
|
+
raise WiringError(
|
|
1583
|
+
f"outbound connection {name!r}: TCP capture_response=True requires expect_reply=True "
|
|
1584
|
+
"(there is no reply to capture otherwise) (ADR 0013)."
|
|
1585
|
+
)
|
|
1586
|
+
if spec.type is ConnectorType.X12 and not spec.settings.get("expect_reply"):
|
|
1587
|
+
raise WiringError(
|
|
1588
|
+
f"outbound connection {name!r}: X12 capture_response=True requires expect_reply=True "
|
|
1589
|
+
"(there is no returned interchange to capture otherwise) (ADR 0016)."
|
|
1590
|
+
)
|
|
1591
|
+
if spec.type is ConnectorType.DATABASE:
|
|
1592
|
+
stmt = str(spec.settings.get("statement") or "").lower()
|
|
1593
|
+
if "returning" not in stmt and "output" not in stmt:
|
|
1594
|
+
raise WiringError(
|
|
1595
|
+
f"outbound connection {name!r}: DATABASE capture_response=True requires a "
|
|
1596
|
+
"RETURNING/OUTPUT clause in the statement (it is fetched from the same cursor "
|
|
1597
|
+
"before commit), not a separate SELECT (ADR 0013)."
|
|
1598
|
+
)
|
|
1599
|
+
# ADR 0015: WS-* / mutual-TLS validity for SOAP, at `check`/dry-run time (no store). The url-scheme
|
|
1600
|
+
# checks (https required for a client cert, cleartext-credential refusal) need the resolved url and
|
|
1601
|
+
# run in SoapDestination.__init__; the structural ones below work on the unresolved spec (an EnvRef
|
|
1602
|
+
# is truthy, so presence/pairing checks hold even before env() resolution).
|
|
1603
|
+
if spec.type is ConnectorType.SOAP:
|
|
1604
|
+
cert = spec.settings.get("client_cert_file")
|
|
1605
|
+
key = spec.settings.get("client_key_file")
|
|
1606
|
+
if bool(cert) != bool(key):
|
|
1607
|
+
raise WiringError(
|
|
1608
|
+
f"outbound connection {name!r}: SOAP client_cert_file and client_key_file must be set "
|
|
1609
|
+
"together (a client cert needs its key) (ADR 0015)."
|
|
1610
|
+
)
|
|
1611
|
+
if cert and spec.settings.get("verify_tls") is False:
|
|
1612
|
+
raise WiringError(
|
|
1613
|
+
f"outbound connection {name!r}: SOAP client cert is incompatible with verify_tls=false "
|
|
1614
|
+
"(presenting an identity to an unverified peer is incoherent) (ADR 0015)."
|
|
1615
|
+
)
|
|
1616
|
+
pw_type = spec.settings.get("ws_password_type", "text")
|
|
1617
|
+
if pw_type not in ("text", "digest"):
|
|
1618
|
+
raise WiringError(
|
|
1619
|
+
f"outbound connection {name!r}: SOAP ws_password_type must be 'text' or 'digest', "
|
|
1620
|
+
f"got {pw_type!r} (ADR 0015)."
|
|
1621
|
+
)
|
|
1622
|
+
if (spec.settings.get("ws_security") or spec.settings.get("ws_addressing")) and str(
|
|
1623
|
+
spec.settings.get("soap_version", "1.1")
|
|
1624
|
+
) != "1.2":
|
|
1625
|
+
raise WiringError(
|
|
1626
|
+
f"outbound connection {name!r}: SOAP ws_security/ws_addressing require "
|
|
1627
|
+
"soap_version='1.2' (WS-Addressing/WS-Security are coherent only on SOAP 1.2) (ADR 0015)."
|
|
1628
|
+
)
|
|
1629
|
+
return OutboundConnection(
|
|
1630
|
+
name=name,
|
|
1631
|
+
spec=spec,
|
|
1632
|
+
retry=retry,
|
|
1633
|
+
ordering=ordering,
|
|
1634
|
+
internal_error=internal_error,
|
|
1635
|
+
buildup=buildup,
|
|
1636
|
+
simulate=simulate,
|
|
1637
|
+
metadata=metadata,
|
|
1638
|
+
source_file=source_file,
|
|
1639
|
+
source_line=source_line,
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def outbound(
|
|
1644
|
+
name: str,
|
|
1645
|
+
spec: ConnectionSpec,
|
|
1646
|
+
*,
|
|
1647
|
+
retry: RetryPolicy | None = None,
|
|
1648
|
+
ordering: OrderingMode | None = None,
|
|
1649
|
+
internal_error: InternalErrorPolicy | None = None,
|
|
1650
|
+
buildup: BuildupThreshold | None = None,
|
|
1651
|
+
simulate: bool = False,
|
|
1652
|
+
metadata: Mapping[str, Any] | None = None,
|
|
1653
|
+
) -> None:
|
|
1654
|
+
"""Declare an outbound connection that Handlers can ``Send`` to.
|
|
1655
|
+
|
|
1656
|
+
``retry``/``ordering``/``internal_error``/``buildup`` override the global ``[delivery]`` defaults
|
|
1657
|
+
for this connection only (omit to inherit). ``ordering`` defaults to FIFO — strict in-order
|
|
1658
|
+
delivery per connection; ``internal_error`` defaults to continue (dead-letter a code-error row and
|
|
1659
|
+
advance); ``buildup`` sets the ``queue_buildup`` alert thresholds for this lane. ``simulate=True``
|
|
1660
|
+
runs the full pipeline but **suppresses the real egress** (shadow / parallel-run mode, #15) — no
|
|
1661
|
+
bytes leave the box and the message still finalizes PROCESSED. ``metadata`` attaches free-form
|
|
1662
|
+
operator labels (Tier 4) surfaced by the API, never used for delivery."""
|
|
1663
|
+
file, line = _call_site()
|
|
1664
|
+
_active_registry().add_outbound(
|
|
1665
|
+
build_outbound_connection(
|
|
1666
|
+
name,
|
|
1667
|
+
spec,
|
|
1668
|
+
retry=retry,
|
|
1669
|
+
ordering=ordering,
|
|
1670
|
+
internal_error=internal_error,
|
|
1671
|
+
buildup=buildup,
|
|
1672
|
+
simulate=simulate,
|
|
1673
|
+
metadata=metadata,
|
|
1674
|
+
source_file=file,
|
|
1675
|
+
source_line=line,
|
|
1676
|
+
)
|
|
1677
|
+
)
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def router(name: str) -> Callable[[RouterFn], RouterFn]:
|
|
1681
|
+
"""Register a Router: ``def route(msg) -> list[str] | str | None`` (handler names; [] => unrouted)."""
|
|
1682
|
+
|
|
1683
|
+
def decorate(fn: RouterFn) -> RouterFn:
|
|
1684
|
+
_active_registry().add_router(name, fn)
|
|
1685
|
+
return fn
|
|
1686
|
+
|
|
1687
|
+
return decorate
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def handler(name: str) -> Callable[[HandlerFn], HandlerFn]:
|
|
1691
|
+
"""Register a Handler: ``def handle(msg) -> Send | SetState | list[Send | SetState] | None``
|
|
1692
|
+
(``None`` => filtered; :class:`SetState` declares a state write applied exactly-once in the
|
|
1693
|
+
handoff, ADR 0005)."""
|
|
1694
|
+
|
|
1695
|
+
def decorate(fn: HandlerFn) -> HandlerFn:
|
|
1696
|
+
_active_registry().add_handler(name, fn)
|
|
1697
|
+
return fn
|
|
1698
|
+
|
|
1699
|
+
return decorate
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
# --- loader ------------------------------------------------------------------
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
class _SiblingHelperFinder:
|
|
1706
|
+
"""Resolve a config module's top-level ``import _helpers`` to a sibling ``.py`` in the config dir.
|
|
1707
|
+
|
|
1708
|
+
The loader runs non-``_`` modules under mangled names and skips ``_``-prefixed files as top-level
|
|
1709
|
+
modules, but CLAUDE.md §4 documents importing shared ``_``-prefixed helpers from siblings. Those
|
|
1710
|
+
files aren't on ``sys.path``, so without a finder Python can't locate them and the import fails
|
|
1711
|
+
(review low-10). Installed on ``sys.meta_path`` only while a config dir loads, and resolves a name
|
|
1712
|
+
only when ``<name>.py`` exists in that dir. :func:`_assert_safe_config_source` already vets every
|
|
1713
|
+
``*.py`` (including ``_*``), so a helper sits inside the same trust boundary as its importers."""
|
|
1714
|
+
|
|
1715
|
+
def __init__(self, directory: Path, created: set[str]) -> None:
|
|
1716
|
+
self._dir = directory
|
|
1717
|
+
self._created = created
|
|
1718
|
+
|
|
1719
|
+
def find_spec(self, fullname: str, path: Any, target: Any = None) -> Any:
|
|
1720
|
+
if path is not None or "." in fullname:
|
|
1721
|
+
return None # only top-level absolute imports, resolved against the config dir
|
|
1722
|
+
candidate = self._dir / f"{fullname}.py"
|
|
1723
|
+
if not candidate.is_file():
|
|
1724
|
+
return None
|
|
1725
|
+
self._created.add(fullname)
|
|
1726
|
+
return importlib.util.spec_from_file_location(fullname, candidate)
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
# Serializes the shared module-global load state (_active, sys.meta_path/sys.modules mutations) so a
|
|
1730
|
+
# reload offloaded to a worker thread can't race a concurrent validate/load (review low-3).
|
|
1731
|
+
_load_lock = threading.Lock()
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
@contextmanager
|
|
1735
|
+
def _loading(directory: Path, registry: Registry) -> Iterator[None]:
|
|
1736
|
+
"""Hold the load lock, publish ``registry`` as the active declaration target **and its code sets
|
|
1737
|
+
as the active set** (so a module-top-level ``code_set(...)`` resolves), and install the
|
|
1738
|
+
sibling-helper import finder for ``directory`` — tearing all of it down (including any helper
|
|
1739
|
+
modules registered under their plain name) on exit."""
|
|
1740
|
+
global _active
|
|
1741
|
+
helpers: set[str] = set()
|
|
1742
|
+
finder = _SiblingHelperFinder(directory, helpers)
|
|
1743
|
+
with _load_lock:
|
|
1744
|
+
_active = registry
|
|
1745
|
+
sys.meta_path.insert(0, finder)
|
|
1746
|
+
# Code sets are published BEFORE the modules run so a top-level capture resolves; the registry
|
|
1747
|
+
# already holds them (loaded in load_config/validate_config), and activated() restores cleanly.
|
|
1748
|
+
try:
|
|
1749
|
+
with _code_sets_activated(registry.code_sets):
|
|
1750
|
+
yield
|
|
1751
|
+
finally:
|
|
1752
|
+
_active = None
|
|
1753
|
+
with suppress(ValueError):
|
|
1754
|
+
sys.meta_path.remove(finder)
|
|
1755
|
+
for name in helpers:
|
|
1756
|
+
sys.modules.pop(name, None)
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
def load_config(directory: str | Path) -> Registry:
|
|
1760
|
+
"""Load every ``*.py`` config module in ``directory`` (sorted; ``_*`` skipped) into a Registry.
|
|
1761
|
+
|
|
1762
|
+
Config modules are **executed** in-process with the engine's full privilege, so the source
|
|
1763
|
+
location is part of the trust boundary: :func:`_assert_safe_config_source` refuses a
|
|
1764
|
+
group/world-writable directory before any code runs. Blocking: an async caller (engine reload)
|
|
1765
|
+
should run this via ``asyncio.to_thread`` so heavy user-config imports don't stall listeners."""
|
|
1766
|
+
directory = Path(directory)
|
|
1767
|
+
# Fail loudly on a missing/typo'd dir: Path.glob() on a nonexistent dir yields nothing, so the
|
|
1768
|
+
# engine would otherwise start with an empty graph — a silently dead interface (review M-24).
|
|
1769
|
+
if not directory.is_dir():
|
|
1770
|
+
raise FileNotFoundError(f"config directory not found: {directory}")
|
|
1771
|
+
_assert_safe_config_source(directory)
|
|
1772
|
+
registry = Registry()
|
|
1773
|
+
# Load the bundle's reference tables (codesets/ relative to the config dir) BEFORE importing the
|
|
1774
|
+
# config modules, so a module-top-level code_set(...) capture resolves. A bad/duplicate table is a
|
|
1775
|
+
# WiringError here (fail loud), like a bad env value; a missing codesets/ dir is fine (no tables).
|
|
1776
|
+
try:
|
|
1777
|
+
registry.code_sets = load_code_sets(directory / CODESETS_DIR_NAME)
|
|
1778
|
+
except CodeSetError as exc:
|
|
1779
|
+
raise WiringError(str(exc)) from exc
|
|
1780
|
+
with _loading(directory, registry):
|
|
1781
|
+
for path in sorted(p for p in directory.glob("*.py") if not p.name.startswith("_")):
|
|
1782
|
+
_exec_module(path)
|
|
1783
|
+
# Connections may also be authored as data (ADR 0007): merge connections.toml into the SAME
|
|
1784
|
+
# registry the code-first inbound()/outbound() calls populated, before validating the whole graph.
|
|
1785
|
+
# Imported lazily to avoid a wiring<->connections_file import cycle. A name in both surfaces is a
|
|
1786
|
+
# duplicate WiringError via add_inbound/add_outbound (no silent precedence).
|
|
1787
|
+
from messagefoundry.config.connections_file import (
|
|
1788
|
+
CONNECTIONS_FILE_NAME,
|
|
1789
|
+
load_connections_file,
|
|
1790
|
+
)
|
|
1791
|
+
|
|
1792
|
+
conn_file = directory / CONNECTIONS_FILE_NAME
|
|
1793
|
+
if conn_file.is_file():
|
|
1794
|
+
load_connections_file(conn_file, registry)
|
|
1795
|
+
registry.validate()
|
|
1796
|
+
return registry
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
# Group-write (0o020) | world-write (0o002): a writable bit for anyone but the owner.
|
|
1800
|
+
_GROUP_WORLD_WRITABLE = 0o022
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _assert_safe_config_source(directory: Path) -> None:
|
|
1804
|
+
"""Refuse to execute config Python from a group/world-writable location (POSIX).
|
|
1805
|
+
|
|
1806
|
+
Because :func:`_exec_module` runs arbitrary Python as the engine's service account, a
|
|
1807
|
+
lower-privileged user who can write into the config dir (or a module file) could execute
|
|
1808
|
+
code as that account on the next reload. On POSIX we hard-fail on a group/world-writable
|
|
1809
|
+
directory or module. On Windows, NTFS DACL enforcement is delegated to the documented
|
|
1810
|
+
install-time ACL (see docs/SERVICE.md) — reading DACLs reliably needs platform APIs out of
|
|
1811
|
+
scope here — and to running under a least-privilege account (docs/SERVICE.md, DEPLOY-1)."""
|
|
1812
|
+
if os.name != "posix" or not directory.is_dir():
|
|
1813
|
+
return
|
|
1814
|
+
# getattr keeps mypy happy on win32 (os.getuid is POSIX-only); we already returned on non-posix.
|
|
1815
|
+
_getuid = getattr(os, "getuid", None)
|
|
1816
|
+
self_uid: int | None = _getuid() if _getuid is not None else None
|
|
1817
|
+
# Include _*.py: the loader skips them as top-level modules, but a sibling can import them, so a
|
|
1818
|
+
# writable/foreign-owned helper is just as much an injection vector (review M-21).
|
|
1819
|
+
candidates = [directory, *directory.glob("*.py")]
|
|
1820
|
+
for path in candidates:
|
|
1821
|
+
try:
|
|
1822
|
+
st = path.stat()
|
|
1823
|
+
except OSError:
|
|
1824
|
+
continue
|
|
1825
|
+
if st.st_mode & _GROUP_WORLD_WRITABLE:
|
|
1826
|
+
raise WiringError(
|
|
1827
|
+
f"refusing to load config from group/world-writable path {path} "
|
|
1828
|
+
f"(mode {oct(st.st_mode & 0o777)}); see docs/SERVICE.md for required permissions"
|
|
1829
|
+
)
|
|
1830
|
+
# Code here runs as the engine's account, so a file owned by a *different* unprivileged user
|
|
1831
|
+
# is an escalation vector even at 0644 — that user can rewrite it (CONFIG-2 / review M-21).
|
|
1832
|
+
if self_uid is not None and self_uid != 0 and st.st_uid != self_uid:
|
|
1833
|
+
raise WiringError(
|
|
1834
|
+
f"refusing to load config from {path} owned by uid {st.st_uid} — the engine runs as "
|
|
1835
|
+
f"uid {self_uid}; that owner could rewrite the executed code (see docs/SERVICE.md)"
|
|
1836
|
+
)
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _exec_module(path: Path) -> None:
|
|
1840
|
+
# Derive a collision-free module name from the resolved absolute path (not just the stem):
|
|
1841
|
+
# two same-stem files in different dirs must not share __module__ (breaks pickling, dataclass
|
|
1842
|
+
# __module__, get_type_hints). Register it in sys.modules so intra-config imports and anything
|
|
1843
|
+
# relying on sys.modules[__name__] resolve correctly; remove it again on failure.
|
|
1844
|
+
digest = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:12]
|
|
1845
|
+
mod_name = f"mefor_config_{path.stem}_{digest}"
|
|
1846
|
+
spec = importlib.util.spec_from_file_location(mod_name, path)
|
|
1847
|
+
if spec is None or spec.loader is None:
|
|
1848
|
+
raise WiringError(f"cannot load config module: {path}")
|
|
1849
|
+
module = importlib.util.module_from_spec(spec)
|
|
1850
|
+
sys.modules[mod_name] = module
|
|
1851
|
+
try:
|
|
1852
|
+
spec.loader.exec_module(module)
|
|
1853
|
+
except WiringError:
|
|
1854
|
+
sys.modules.pop(mod_name, None)
|
|
1855
|
+
raise
|
|
1856
|
+
except Exception as exc:
|
|
1857
|
+
sys.modules.pop(mod_name, None)
|
|
1858
|
+
raise WiringError(f"error loading config module {path.name}: {exc}") from exc
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def validate_config(directory: str | Path) -> list[Diagnostic]:
|
|
1862
|
+
"""Load ``directory`` best-effort and return **all** problems (not just the first).
|
|
1863
|
+
|
|
1864
|
+
Unlike :func:`load_config`, a bad module is recorded and loading continues, and every
|
|
1865
|
+
unresolved ``inbound → router`` reference is reported — so an editor can show the full set
|
|
1866
|
+
at once. Returns ``[]`` when the config is valid.
|
|
1867
|
+
"""
|
|
1868
|
+
directory = Path(directory)
|
|
1869
|
+
if not directory.is_dir(): # fail loudly, not silently empty (review M-24)
|
|
1870
|
+
return [Diagnostic(message=f"config directory not found: {directory}", file=str(directory))]
|
|
1871
|
+
try:
|
|
1872
|
+
# Same trust boundary as load_config: never execute Python from an unsafe source (review low-11).
|
|
1873
|
+
_assert_safe_config_source(directory)
|
|
1874
|
+
except WiringError as exc:
|
|
1875
|
+
return [Diagnostic(message=str(exc), file=str(directory))]
|
|
1876
|
+
registry = Registry()
|
|
1877
|
+
diagnostics: list[Diagnostic] = []
|
|
1878
|
+
# Load reference tables first (so a module-top-level code_set(...) resolves during import). A
|
|
1879
|
+
# bad/duplicate table is recorded as a diagnostic, not raised, so the editor sees every problem.
|
|
1880
|
+
codesets_dir = directory / CODESETS_DIR_NAME
|
|
1881
|
+
try:
|
|
1882
|
+
registry.code_sets = load_code_sets(codesets_dir)
|
|
1883
|
+
except CodeSetError as exc:
|
|
1884
|
+
diagnostics.append(Diagnostic(message=str(exc), file=str(codesets_dir)))
|
|
1885
|
+
with _loading(directory, registry):
|
|
1886
|
+
for path in sorted(p for p in directory.glob("*.py") if not p.name.startswith("_")):
|
|
1887
|
+
try:
|
|
1888
|
+
_exec_module(path)
|
|
1889
|
+
except WiringError as exc:
|
|
1890
|
+
diagnostics.append(Diagnostic(message=str(exc), file=str(path)))
|
|
1891
|
+
# Merge connections.toml best-effort too (ADR 0007), so the editor sees TOML problems alongside the
|
|
1892
|
+
# *.py ones and the router/port checks below cover TOML-authored connections. Lazy import (cycle).
|
|
1893
|
+
from messagefoundry.config.connections_file import (
|
|
1894
|
+
CONNECTIONS_FILE_NAME,
|
|
1895
|
+
load_connections_file,
|
|
1896
|
+
)
|
|
1897
|
+
|
|
1898
|
+
conn_file = directory / CONNECTIONS_FILE_NAME
|
|
1899
|
+
if conn_file.is_file():
|
|
1900
|
+
try:
|
|
1901
|
+
load_connections_file(conn_file, registry)
|
|
1902
|
+
except WiringError as exc:
|
|
1903
|
+
diagnostics.append(Diagnostic(message=str(exc), file=str(conn_file)))
|
|
1904
|
+
for conn in registry.inbound.values():
|
|
1905
|
+
if conn.router not in registry.routers:
|
|
1906
|
+
diagnostics.append(
|
|
1907
|
+
Diagnostic(
|
|
1908
|
+
message=f"inbound connection {conn.name!r} references unknown router "
|
|
1909
|
+
f"{conn.router!r}"
|
|
1910
|
+
)
|
|
1911
|
+
)
|
|
1912
|
+
for port, first, second in registry.port_collisions(): # low-13
|
|
1913
|
+
diagnostics.append(
|
|
1914
|
+
Diagnostic(
|
|
1915
|
+
message=f"inbound connections {first!r} and {second!r} both bind port {port}"
|
|
1916
|
+
)
|
|
1917
|
+
)
|
|
1918
|
+
return diagnostics
|