@atomic-ehr/codegen 0.0.15 → 0.0.16-canary.20260623091125.83276af
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.
- package/README.md +22 -16
- package/assets/api/writer-generator/python/fhirpy_base_model.py +1 -0
- package/assets/api/writer-generator/python/profile_helpers.py +502 -0
- package/assets/api/writer-generator/python/resource_preprocessor.py +41 -0
- package/dist/cli/index.js +5 -5
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1577 -422
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/assets/api/writer-generator/python/resource_family_validator.py +0 -92
package/README.md
CHANGED
|
@@ -35,12 +35,12 @@ A powerful, extensible code generation toolkit for FHIR ([Fast Healthcare Intero
|
|
|
35
35
|
|
|
36
36
|
## Features
|
|
37
37
|
|
|
38
|
-
- [x] **Multi-Package Support** — Load packages from the [FHIR registry](examples/typescript-r4/), [remote TGZ files](examples/typescript-
|
|
38
|
+
- [x] **Multi-Package Support** — Load packages from the [FHIR registry](examples/typescript-r4-us-core/), [remote TGZ files](examples/typescript-custom-packages/), or a [local folder with custom StructureDefinitions](examples/typescript-custom-packages/)
|
|
39
39
|
- Tested with hl7.fhir.r4.core, US Core, C-CDA, SQL on FHIR, etc.
|
|
40
40
|
- [x] **Resources & Complex Types** — Generates typed definitions with proper inheritance
|
|
41
41
|
- [x] **Value Set Bindings** — Strongly-typed enums from FHIR terminology bindings
|
|
42
|
-
- [x] **Profiles** — Factory methods with auto-populated fixed values and required slices ([R4 profiles](examples/typescript-r4/profile-bp.test.ts), [US Core](examples/typescript-us-core/))
|
|
43
|
-
- Extensions — flat typed accessors (e.g. `setRace()` on US Core Patient), [standalone extension profiles](examples/typescript-r4/extension
|
|
42
|
+
- [x] **Profiles** — Factory methods with auto-populated fixed values and required slices ([R4 profiles](examples/typescript-r4-us-core/profile-r4-bp.test.ts), [US Core](examples/typescript-r4-us-core/profile-us-core-patient.test.ts))
|
|
43
|
+
- Extensions — flat typed accessors (e.g. `setRace()` on US Core Patient), [standalone extension profiles](examples/typescript-r4-us-core/profile-r4-extension.test.ts)
|
|
44
44
|
- Slicing — typed get/set accessors with discriminator matching
|
|
45
45
|
- Validation — runtime `validate()` for required fields, fixed values, slice cardinality, enums, references
|
|
46
46
|
- [x] **Extensible Architecture** — Three-stage pipeline: FHIR packages → [TypeSchema](https://www.health-samurai.io/articles/type-schema-a-pragmatic-approach-to-build-fhir-sdk) IR → code generation
|
|
@@ -58,9 +58,9 @@ A powerful, extensible code generation toolkit for FHIR ([Fast Healthcare Intero
|
|
|
58
58
|
| Resources & Complex Types | yes | yes | yes | template |
|
|
59
59
|
| Polymorphic container `Bundle<T>` | yes | yes | no | no |
|
|
60
60
|
| Value Set Bindings | inline | limited | enum | template |
|
|
61
|
-
| Primitive Extensions | yes |
|
|
62
|
-
| Profiles | yes |
|
|
63
|
-
| Profile Validation | yes |
|
|
61
|
+
| Primitive Extensions | yes | opt-in | no | no |
|
|
62
|
+
| Profiles | yes | yes | no | no |
|
|
63
|
+
| Profile Validation | yes | yes | no | no |
|
|
64
64
|
|
|
65
65
|
## Guides
|
|
66
66
|
|
|
@@ -99,7 +99,7 @@ yarn add @atomic-ehr/codegen
|
|
|
99
99
|
const builder = new APIBuilder()
|
|
100
100
|
.fromPackage("hl7.fhir.r4.core", "4.0.1")
|
|
101
101
|
.typescript({})
|
|
102
|
-
.outputTo("./examples/typescript-r4/fhir-types")
|
|
102
|
+
.outputTo("./examples/typescript-r4-us-core/fhir-types")
|
|
103
103
|
.introspection({ typeTree: "./type-tree.yaml" });
|
|
104
104
|
|
|
105
105
|
const report = await builder.generate();
|
|
@@ -116,14 +116,13 @@ yarn add @atomic-ehr/codegen
|
|
|
116
116
|
|
|
117
117
|
See the [examples/](examples/) directory for working demonstrations:
|
|
118
118
|
|
|
119
|
-
- **[typescript-r4/](examples/typescript-r4/)** - FHIR R4 type generation with resource creation
|
|
120
|
-
- **[
|
|
121
|
-
- **[
|
|
122
|
-
- **[python/](examples/python/)** - Python/Pydantic model generation with simple requests-based client
|
|
123
|
-
- **[python-fhirpy/](examples/python-fhirpy/)** - Python/Pydantic model generation with fhirpy async client
|
|
119
|
+
- **[typescript-r4-us-core/](examples/typescript-r4-us-core/)** - FHIR R4 core + US Core type generation with resource creation, profiles, and extensions
|
|
120
|
+
- **[on-the-fly/ccda/](examples/on-the-fly/ccda/)** - C-CDA on FHIR type generation (logical models, generated on the fly)
|
|
121
|
+
- **[python-r4-us-core/](examples/python-r4-us-core/)** - Python/Pydantic models for FHIR R4 core + US Core profiles, with the default fhirpy async client
|
|
122
|
+
- **[python-r4/](examples/python-r4/)** - Python/Pydantic model generation with the simple requests-based client
|
|
124
123
|
- **[csharp/](examples/csharp/)** - C# class generation with namespace configuration
|
|
125
124
|
- **[mustache/](examples/mustache/)** - Java generation with Mustache templates and post-generation hooks
|
|
126
|
-
- **[
|
|
125
|
+
- **[typescript-custom-packages/](examples/typescript-custom-packages/)** - Loading packages from local folders or remote TGZ URLs (SQL-on-FHIR)
|
|
127
126
|
|
|
128
127
|
For detailed documentation, see [examples/README.md](examples/README.md).
|
|
129
128
|
|
|
@@ -159,11 +158,16 @@ const builder = new APIBuilder()
|
|
|
159
158
|
openResourceTypeSet?: boolean,
|
|
160
159
|
})
|
|
161
160
|
.python({ // Python generator
|
|
161
|
+
client?: "fhirpy" | "none", // client integration (default: fhirpy)
|
|
162
|
+
generateProfile?: boolean, // generate profile wrapper classes
|
|
163
|
+
primitiveTypeExtension?: boolean,
|
|
162
164
|
allowExtraFields?: boolean,
|
|
163
165
|
fieldFormat?: "snake_case" | "camelCase",
|
|
164
|
-
staticDir?: string,
|
|
165
166
|
})
|
|
166
|
-
.csharp(
|
|
167
|
+
.csharp({ // C# generator
|
|
168
|
+
rootNamespace: "Fhir.Types",
|
|
169
|
+
staticSourceDir?: "./static",
|
|
170
|
+
})
|
|
167
171
|
|
|
168
172
|
// Output configuration
|
|
169
173
|
.outputTo("./generated/types") // Output directory
|
|
@@ -423,7 +427,9 @@ const errors = bp.validate();
|
|
|
423
427
|
// ["effective: at least one of effectiveDateTime, effectivePeriod is required"]
|
|
424
428
|
```
|
|
425
429
|
|
|
426
|
-
See [examples/typescript-r4
|
|
430
|
+
See [examples/typescript-r4-us-core/](examples/typescript-r4-us-core/) for R4 and US Core profile tests.
|
|
431
|
+
|
|
432
|
+
Python (`generateProfile: true`) produces equivalent profile classes — `create()`, typed accessors, and `validate()` — wrapping a Pydantic model via `_resource`. See [examples/python-r4-us-core/](examples/python-r4-us-core/).
|
|
427
433
|
|
|
428
434
|
## Support
|
|
429
435
|
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime helpers for generated FHIR profile classes.
|
|
3
|
+
|
|
4
|
+
This file is copied verbatim into every generated Python output and imported by
|
|
5
|
+
profile modules. It provides:
|
|
6
|
+
|
|
7
|
+
- **Slice helpers** – match, get, set, and default-fill array slices defined by
|
|
8
|
+
a FHIR StructureDefinition.
|
|
9
|
+
- **Extension helpers** – read complex (nested) FHIR extensions into plain dicts.
|
|
10
|
+
- **Choice-type helpers** – wrap/unwrap polymorphic ``value[x]`` fields so
|
|
11
|
+
profile classes can expose a flat API.
|
|
12
|
+
- **Validation helpers** – lightweight structural checks that profile classes
|
|
13
|
+
call from their ``validate()`` method.
|
|
14
|
+
- **Misc utilities** – deep-match, deep-merge, path navigation.
|
|
15
|
+
|
|
16
|
+
The helpers operate on plain ``dict`` / ``list`` structures. Profile classes
|
|
17
|
+
own a Pydantic resource instance (``self._resource``); when a helper needs the
|
|
18
|
+
underlying data, the profile passes ``self._resource.model_dump(by_alias=True,
|
|
19
|
+
exclude_none=True)`` or accesses model fields directly. All ``validate_*``
|
|
20
|
+
functions return ``list[str]`` so a profile's ``validate()`` can concatenate
|
|
21
|
+
them into a single errors / warnings list.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import copy
|
|
27
|
+
from typing import Any, Iterable, Mapping, MutableMapping, MutableSequence, Sequence, TypeVar
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# General utilities
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_record(value: Any) -> bool:
|
|
37
|
+
"""True when ``value`` is a non-None mapping (dict-like, not a list)."""
|
|
38
|
+
return isinstance(value, Mapping)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_path(root: MutableMapping[str, Any], path: Sequence[str]) -> MutableMapping[str, Any]:
|
|
42
|
+
"""Walk ``path`` from ``root``, creating intermediate dicts (or using the
|
|
43
|
+
first element of an existing list) as needed. Returns the leaf mapping.
|
|
44
|
+
|
|
45
|
+
Used by extension setters to reach a nested target inside a resource dict.
|
|
46
|
+
"""
|
|
47
|
+
current: MutableMapping[str, Any] = root
|
|
48
|
+
for segment in path:
|
|
49
|
+
nxt = current.get(segment)
|
|
50
|
+
if isinstance(nxt, list):
|
|
51
|
+
if len(nxt) == 0:
|
|
52
|
+
nxt.append({})
|
|
53
|
+
current = nxt[0]
|
|
54
|
+
else:
|
|
55
|
+
if not isinstance(nxt, MutableMapping):
|
|
56
|
+
nxt = {}
|
|
57
|
+
current[segment] = nxt
|
|
58
|
+
current = nxt
|
|
59
|
+
return current
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Deep match / merge
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def merge_match(target: MutableMapping[str, Any], match: Mapping[str, Any]) -> None:
|
|
68
|
+
"""Deep-merge ``match`` into ``target``, mutating ``target`` in place."""
|
|
69
|
+
for key, match_value in match.items():
|
|
70
|
+
if is_record(match_value):
|
|
71
|
+
existing = target.get(key)
|
|
72
|
+
if is_record(existing):
|
|
73
|
+
merge_match(existing, match_value)
|
|
74
|
+
else:
|
|
75
|
+
target[key] = dict(match_value)
|
|
76
|
+
else:
|
|
77
|
+
target[key] = match_value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def apply_slice_match(input_obj: Mapping[str, Any], match: Mapping[str, Any]) -> dict[str, Any]:
|
|
81
|
+
"""Shallow-clone ``input_obj`` then deep-merge ``match`` on top, returning
|
|
82
|
+
a complete slice element ready for insertion."""
|
|
83
|
+
result: dict[str, Any] = dict(input_obj)
|
|
84
|
+
merge_match(result, match)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_key(obj: Any, key: str) -> Any:
|
|
89
|
+
"""Retrieve ``key`` from a dict-like or Pydantic-model-like object."""
|
|
90
|
+
if is_record(obj):
|
|
91
|
+
return obj.get(key)
|
|
92
|
+
return getattr(obj, key, None)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _model_get(value: Any, key: str) -> Any:
|
|
96
|
+
"""Get an attribute from a Pydantic model by Python name or field alias.
|
|
97
|
+
|
|
98
|
+
Pydantic stores fields as snake_case attributes but slice match dicts use
|
|
99
|
+
camelCase aliases (e.g. ``resourceType``). Try the direct attribute first;
|
|
100
|
+
fall back to scanning ``model_fields`` for a matching alias.
|
|
101
|
+
"""
|
|
102
|
+
direct = getattr(value, key, None)
|
|
103
|
+
if direct is not None:
|
|
104
|
+
return direct
|
|
105
|
+
model_fields = getattr(type(value), "model_fields", None)
|
|
106
|
+
if model_fields:
|
|
107
|
+
for field_name, field_info in model_fields.items():
|
|
108
|
+
if field_info.alias == key or field_info.serialization_alias == key:
|
|
109
|
+
return getattr(value, field_name, None)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def matches_value(value: Any, match: Any) -> bool:
|
|
114
|
+
"""Recursively test whether ``value`` structurally contains everything in
|
|
115
|
+
``match``. Lists use "every match item has a corresponding value item"
|
|
116
|
+
semantics; mappings are matched key-by-key; primitives use ``==``.
|
|
117
|
+
|
|
118
|
+
Works with both plain dicts and Pydantic model instances. When ``match``
|
|
119
|
+
is a record and ``value`` is a list, returns ``True`` if any element in
|
|
120
|
+
``value`` satisfies the record match (handles nested array fields in FHIR
|
|
121
|
+
discriminator patterns).
|
|
122
|
+
|
|
123
|
+
Core discriminator check used to identify which array element belongs to a
|
|
124
|
+
given FHIR slice.
|
|
125
|
+
"""
|
|
126
|
+
if isinstance(match, list):
|
|
127
|
+
if not isinstance(value, list):
|
|
128
|
+
return False
|
|
129
|
+
return all(any(matches_value(item, m_item) for item in value) for m_item in match)
|
|
130
|
+
if is_record(match):
|
|
131
|
+
if value is None:
|
|
132
|
+
return False
|
|
133
|
+
# Record match against a list: check any element matches
|
|
134
|
+
if isinstance(value, list):
|
|
135
|
+
return any(matches_value(item, match) for item in value)
|
|
136
|
+
# Plain dict
|
|
137
|
+
if is_record(value):
|
|
138
|
+
for key, m_val in match.items():
|
|
139
|
+
if not matches_value(value.get(key), m_val):
|
|
140
|
+
return False
|
|
141
|
+
return True
|
|
142
|
+
# Pydantic model (or any object with attributes) — use alias-aware lookup
|
|
143
|
+
if hasattr(value, "__dict__"):
|
|
144
|
+
for key, m_val in match.items():
|
|
145
|
+
if not matches_value(_model_get(value, key), m_val):
|
|
146
|
+
return False
|
|
147
|
+
return True
|
|
148
|
+
return False
|
|
149
|
+
return bool(value == match)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_extension(value: Any, url: str | None = None) -> bool:
|
|
153
|
+
"""True when ``value`` looks like a raw FHIR Extension (has a ``url``).
|
|
154
|
+
When ``url`` is given, also checks the URL matches.
|
|
155
|
+
Works with both plain dicts and Pydantic model instances."""
|
|
156
|
+
ext_url = _get_key(value, "url") if (is_record(value) or hasattr(value, "__dict__")) else None
|
|
157
|
+
if ext_url is None:
|
|
158
|
+
return False
|
|
159
|
+
return url is None or bool(ext_url == url)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_extension_value(ext: Any | None, field: str) -> Any:
|
|
163
|
+
"""Read a single typed value field from an Extension dict or Pydantic model,
|
|
164
|
+
returning ``None`` when the extension itself is absent or the field is not set."""
|
|
165
|
+
if ext is None:
|
|
166
|
+
return None
|
|
167
|
+
return _get_key(ext, field)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def push_extension(target: Any, ext: Any) -> None:
|
|
171
|
+
"""Push an extension onto ``target.extension`` (Pydantic model) or
|
|
172
|
+
``target['extension']`` (dict), creating the list if absent. ``ext`` may
|
|
173
|
+
be either a dict-like mapping or a Pydantic model instance — mappings are
|
|
174
|
+
shallow-copied, Pydantic models are stored as-is so attribute access and
|
|
175
|
+
nested model instances are preserved."""
|
|
176
|
+
lst = getattr(target, "extension", None) if hasattr(target, "__dict__") else target.get("extension")
|
|
177
|
+
if not isinstance(lst, list):
|
|
178
|
+
lst = []
|
|
179
|
+
if hasattr(ext, "model_dump"):
|
|
180
|
+
lst.append(ext)
|
|
181
|
+
else:
|
|
182
|
+
lst.append(dict(ext))
|
|
183
|
+
if hasattr(target, "__dict__"):
|
|
184
|
+
setattr(target, "extension", lst)
|
|
185
|
+
else:
|
|
186
|
+
target["extension"] = lst
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# Extension helpers
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def extract_complex_extension(
|
|
195
|
+
extension: Any | None,
|
|
196
|
+
config: Sequence[Mapping[str, Any]],
|
|
197
|
+
) -> dict[str, Any] | None:
|
|
198
|
+
"""Read a complex (nested) FHIR extension into a plain key/value dict.
|
|
199
|
+
|
|
200
|
+
Each entry in ``config`` describes one sub-extension by ``name`` (URL),
|
|
201
|
+
``valueField`` (e.g. ``"valueString"``), and ``isArray``.
|
|
202
|
+
|
|
203
|
+
Works with both plain dicts and Pydantic model instances.
|
|
204
|
+
"""
|
|
205
|
+
if extension is None:
|
|
206
|
+
return None
|
|
207
|
+
sub_exts = _get_key(extension, "extension")
|
|
208
|
+
if not isinstance(sub_exts, list):
|
|
209
|
+
return None
|
|
210
|
+
result: dict[str, Any] = {}
|
|
211
|
+
for entry in config:
|
|
212
|
+
name = entry["name"]
|
|
213
|
+
value_field = entry["valueField"]
|
|
214
|
+
is_array = bool(entry["isArray"])
|
|
215
|
+
matched = [e for e in sub_exts if _get_key(e, "url") == name]
|
|
216
|
+
if is_array:
|
|
217
|
+
result[name] = [_get_key(e, value_field) for e in matched]
|
|
218
|
+
elif matched:
|
|
219
|
+
result[name] = _get_key(matched[0], value_field)
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# Slice helpers
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def strip_match_keys(slice_obj: Mapping[str, Any], match_keys: Sequence[str]) -> dict[str, Any]:
|
|
229
|
+
"""Remove discriminator keys from a slice element, returning only the
|
|
230
|
+
user-supplied portion."""
|
|
231
|
+
result = dict(slice_obj)
|
|
232
|
+
for key in match_keys:
|
|
233
|
+
result.pop(key, None)
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def wrap_slice_choice(input_obj: Mapping[str, Any], choice_variant: str) -> dict[str, Any]:
|
|
238
|
+
"""Wrap a flat input dict under a choice-type key. No-op when ``input_obj``
|
|
239
|
+
is empty."""
|
|
240
|
+
if len(input_obj) == 0:
|
|
241
|
+
return dict(input_obj)
|
|
242
|
+
return {choice_variant: dict(input_obj)}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def unwrap_slice_choice(
|
|
246
|
+
slice_obj: Mapping[str, Any],
|
|
247
|
+
match_keys: Sequence[str],
|
|
248
|
+
choice_variant: str,
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
"""Inverse of :func:`wrap_slice_choice`: strip discriminator keys, then
|
|
251
|
+
hoist the value inside ``choice_variant`` up to the top level."""
|
|
252
|
+
result = dict(slice_obj)
|
|
253
|
+
for key in match_keys:
|
|
254
|
+
result.pop(key, None)
|
|
255
|
+
variant_value = result.pop(choice_variant, None)
|
|
256
|
+
if is_record(variant_value):
|
|
257
|
+
for k, v in variant_value.items():
|
|
258
|
+
result[k] = v
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def ensure_slice_defaults(items: MutableSequence[Any], *matches: Mapping[str, Any]) -> MutableSequence[Any]:
|
|
263
|
+
"""Ensure that every required slice has at least a stub element in the
|
|
264
|
+
array. If no existing item satisfies a ``match``, a deep clone of the
|
|
265
|
+
pattern is appended."""
|
|
266
|
+
for match in matches:
|
|
267
|
+
if not any(matches_value(item, match) for item in items):
|
|
268
|
+
items.append(copy.deepcopy(dict(match)))
|
|
269
|
+
return items
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def build_resource(resource_cls: type[T], /, **fields: Any) -> T:
|
|
273
|
+
"""Instantiate a Pydantic resource class from kwargs, dropping ``None``
|
|
274
|
+
values so optional fields don't appear in the dump.
|
|
275
|
+
|
|
276
|
+
Centralises construction so generators don't need to import every model.
|
|
277
|
+
"""
|
|
278
|
+
cleaned = {k: v for k, v in fields.items() if v is not None}
|
|
279
|
+
return resource_cls(**cleaned)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def ensure_profile(resource: Any, canonical_url: str) -> None:
|
|
283
|
+
"""Add ``canonical_url`` to ``resource.meta.profile`` if not already
|
|
284
|
+
present. Works on both Pydantic models and plain dicts; creates ``meta``
|
|
285
|
+
and ``profile`` when missing."""
|
|
286
|
+
if isinstance(resource, MutableMapping):
|
|
287
|
+
meta = resource.get("meta")
|
|
288
|
+
if not isinstance(meta, MutableMapping):
|
|
289
|
+
meta = {}
|
|
290
|
+
resource["meta"] = meta
|
|
291
|
+
profiles = meta.get("profile")
|
|
292
|
+
if not isinstance(profiles, list):
|
|
293
|
+
profiles = []
|
|
294
|
+
meta["profile"] = profiles
|
|
295
|
+
if canonical_url not in profiles:
|
|
296
|
+
profiles.append(canonical_url)
|
|
297
|
+
return
|
|
298
|
+
# Pydantic model path
|
|
299
|
+
meta = getattr(resource, "meta", None)
|
|
300
|
+
if meta is None:
|
|
301
|
+
# Try to construct a Meta from the model's annotation
|
|
302
|
+
meta_field = type(resource).model_fields.get("meta") if hasattr(type(resource), "model_fields") else None
|
|
303
|
+
if meta_field is not None and meta_field.annotation is not None:
|
|
304
|
+
try:
|
|
305
|
+
import types as _types
|
|
306
|
+
import typing as _typing
|
|
307
|
+
ann = meta_field.annotation
|
|
308
|
+
# Unwrap Optional[T] / Union[T, None] / T | None to get the actual class
|
|
309
|
+
origin = getattr(ann, "__origin__", None)
|
|
310
|
+
if origin is _typing.Union or isinstance(ann, _types.UnionType):
|
|
311
|
+
args = [a for a in ann.__args__ if a is not type(None)]
|
|
312
|
+
if args:
|
|
313
|
+
ann = args[0]
|
|
314
|
+
meta = ann(profile=[canonical_url])
|
|
315
|
+
resource.meta = meta
|
|
316
|
+
return
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
319
|
+
# Fallback: shouldn't happen for FHIR resources
|
|
320
|
+
return
|
|
321
|
+
profiles = getattr(meta, "profile", None)
|
|
322
|
+
if profiles is None:
|
|
323
|
+
meta.profile = [canonical_url]
|
|
324
|
+
elif canonical_url not in profiles:
|
|
325
|
+
profiles.append(canonical_url)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def set_array_slice(lst: MutableSequence[Any], match: Mapping[str, Any], value: Any) -> None:
|
|
329
|
+
"""Find or insert a slice element. If an element matching ``match``
|
|
330
|
+
already exists it is replaced in place; otherwise ``value`` is appended."""
|
|
331
|
+
for i, item in enumerate(lst):
|
|
332
|
+
if matches_value(item, match):
|
|
333
|
+
lst[i] = value
|
|
334
|
+
return
|
|
335
|
+
lst.append(value)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_array_slice(lst: Sequence[Any] | None, match: Mapping[str, Any]) -> Any:
|
|
339
|
+
"""Return the first element in ``lst`` that satisfies ``match``."""
|
|
340
|
+
if lst is None:
|
|
341
|
+
return None
|
|
342
|
+
for item in lst:
|
|
343
|
+
if matches_value(item, match):
|
|
344
|
+
return item
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def get_array_slices(lst: Sequence[Any] | None, match: Mapping[str, Any]) -> list[Any]:
|
|
349
|
+
"""Return all elements in ``lst`` that satisfy ``match``."""
|
|
350
|
+
if lst is None:
|
|
351
|
+
return []
|
|
352
|
+
return [item for item in lst if matches_value(item, match)]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def set_array_slices(
|
|
356
|
+
lst: MutableSequence[Any],
|
|
357
|
+
match: Mapping[str, Any],
|
|
358
|
+
values: Sequence[Any],
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Remove all elements matching ``match``, then append ``values``."""
|
|
361
|
+
indices = [i for i, item in enumerate(lst) if matches_value(item, match)]
|
|
362
|
+
for i in reversed(indices):
|
|
363
|
+
del lst[i]
|
|
364
|
+
lst.extend(values)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# Validation helpers
|
|
369
|
+
#
|
|
370
|
+
# Each function returns a list of human-readable error strings (empty = ok).
|
|
371
|
+
# Profile classes concatenate them all into a single list inside validate().
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _get_field(res: Any, field: str) -> Any:
|
|
376
|
+
if isinstance(res, Mapping):
|
|
377
|
+
return res.get(field)
|
|
378
|
+
return getattr(res, field, None)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def validate_required(res: Any, profile_name: str, field: str) -> list[str]:
|
|
382
|
+
"""Checks that ``field`` is present (not ``None``)."""
|
|
383
|
+
return (
|
|
384
|
+
[f"{profile_name}: required field '{field}' is missing"]
|
|
385
|
+
if _get_field(res, field) is None
|
|
386
|
+
else []
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def validate_must_support(res: Any, profile_name: str, field: str) -> list[str]:
|
|
391
|
+
"""Checks that a must-support field is populated (warning, not error)."""
|
|
392
|
+
return (
|
|
393
|
+
[f"{profile_name}: must-support field '{field}' is not populated"]
|
|
394
|
+
if _get_field(res, field) is None
|
|
395
|
+
else []
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def validate_excluded(res: Any, profile_name: str, field: str) -> list[str]:
|
|
400
|
+
"""Checks that ``field`` is absent."""
|
|
401
|
+
return (
|
|
402
|
+
[f"{profile_name}: field '{field}' must not be present"]
|
|
403
|
+
if _get_field(res, field) is not None
|
|
404
|
+
else []
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def validate_fixed_value(res: Any, profile_name: str, field: str, expected: Any) -> list[str]:
|
|
409
|
+
"""Checks that ``field`` structurally contains the expected fixed value."""
|
|
410
|
+
actual = _get_field(res, field)
|
|
411
|
+
return (
|
|
412
|
+
[]
|
|
413
|
+
if matches_value(actual, expected)
|
|
414
|
+
else [f"{profile_name}: field '{field}' does not match expected fixed value"]
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def validate_slice_cardinality(
|
|
419
|
+
res: Any,
|
|
420
|
+
profile_name: str,
|
|
421
|
+
field: str,
|
|
422
|
+
match: Mapping[str, Any],
|
|
423
|
+
slice_name: str,
|
|
424
|
+
min_count: int,
|
|
425
|
+
max_count: int,
|
|
426
|
+
) -> list[str]:
|
|
427
|
+
"""Checks that the number of array elements matching ``match`` falls
|
|
428
|
+
within ``[min_count, max_count]``. Pass ``max_count = 0`` for unbounded."""
|
|
429
|
+
items = _get_field(res, field) or []
|
|
430
|
+
if not isinstance(items, Iterable):
|
|
431
|
+
items = []
|
|
432
|
+
count = sum(1 for item in items if matches_value(item, match))
|
|
433
|
+
errors: list[str] = []
|
|
434
|
+
if count < min_count:
|
|
435
|
+
errors.append(
|
|
436
|
+
f"{profile_name}.{field}: slice '{slice_name}' requires at least {min_count} item(s), found {count}"
|
|
437
|
+
)
|
|
438
|
+
if max_count > 0 and count > max_count:
|
|
439
|
+
errors.append(
|
|
440
|
+
f"{profile_name}.{field}: slice '{slice_name}' allows at most {max_count} item(s), found {count}"
|
|
441
|
+
)
|
|
442
|
+
return errors
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def validate_choice_required(res: Any, profile_name: str, choices: Sequence[str]) -> list[str]:
|
|
446
|
+
"""Checks that at least one of the listed choice-type variants is present."""
|
|
447
|
+
if any(_get_field(res, c) is not None for c in choices):
|
|
448
|
+
return []
|
|
449
|
+
return [f"{profile_name}: at least one of {', '.join(choices)} is required"]
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def validate_enum(res: Any, profile_name: str, field: str, allowed: Sequence[str]) -> list[str]:
|
|
453
|
+
"""Checks that the value of ``field`` has a code within ``allowed``.
|
|
454
|
+
Handles plain strings, Coding, and CodeableConcept."""
|
|
455
|
+
value = _get_field(res, field)
|
|
456
|
+
if value is None:
|
|
457
|
+
return []
|
|
458
|
+
if isinstance(value, str):
|
|
459
|
+
return (
|
|
460
|
+
[]
|
|
461
|
+
if value in allowed
|
|
462
|
+
else [f"{profile_name}: field '{field}' value '{value}' is not in allowed values"]
|
|
463
|
+
)
|
|
464
|
+
# Coding
|
|
465
|
+
code = _get_field(value, "code")
|
|
466
|
+
system = _get_field(value, "system")
|
|
467
|
+
if isinstance(code, str) and system is not None:
|
|
468
|
+
return (
|
|
469
|
+
[]
|
|
470
|
+
if code in allowed
|
|
471
|
+
else [f"{profile_name}: field '{field}' code '{code}' is not in allowed values"]
|
|
472
|
+
)
|
|
473
|
+
# CodeableConcept
|
|
474
|
+
coding = _get_field(value, "coding")
|
|
475
|
+
if isinstance(coding, list):
|
|
476
|
+
codes = [_get_field(c, "code") for c in coding]
|
|
477
|
+
codes = [c for c in codes if isinstance(c, str)]
|
|
478
|
+
if any(c in allowed for c in codes):
|
|
479
|
+
return []
|
|
480
|
+
return [f"{profile_name}: field '{field}' has no coding with an allowed code"]
|
|
481
|
+
return []
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def validate_reference(res: Any, profile_name: str, field: str, allowed: Sequence[str]) -> list[str]:
|
|
485
|
+
"""Checks that a Reference field points to one of the ``allowed`` resource
|
|
486
|
+
types. Extracts the type from the ``reference`` string (the part before
|
|
487
|
+
the first ``/``)."""
|
|
488
|
+
value = _get_field(res, field)
|
|
489
|
+
if value is None:
|
|
490
|
+
return []
|
|
491
|
+
ref = _get_field(value, "reference")
|
|
492
|
+
if not isinstance(ref, str):
|
|
493
|
+
return []
|
|
494
|
+
slash = ref.find("/")
|
|
495
|
+
if slash == -1:
|
|
496
|
+
return []
|
|
497
|
+
ref_type = ref[:slash]
|
|
498
|
+
if ref_type in allowed:
|
|
499
|
+
return []
|
|
500
|
+
return [
|
|
501
|
+
f"{profile_name}: field '{field}' references '{ref_type}' but only {', '.join(allowed)} are allowed"
|
|
502
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import importlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _to_snake_case(name: str) -> str:
|
|
10
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _import_resource_class(package: str, resource_type: str) -> Any:
|
|
14
|
+
module_name = f"{package}.{_to_snake_case(resource_type)}"
|
|
15
|
+
if importlib.util.find_spec(module_name) is None:
|
|
16
|
+
return None
|
|
17
|
+
module = importlib.import_module(module_name)
|
|
18
|
+
return getattr(module, resource_type, None)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _preprocess_value(value: Any, package: str) -> Any:
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
resource_type = value.get("resourceType")
|
|
24
|
+
if resource_type and isinstance(resource_type, str):
|
|
25
|
+
cls = _import_resource_class(package, resource_type)
|
|
26
|
+
if cls is not None:
|
|
27
|
+
return cls.model_validate(value)
|
|
28
|
+
return {k: _preprocess_value(v, package) for k, v in value.items()}
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
return [_preprocess_value(item, package) for item in value]
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def preprocess_resource_fields(data: dict[str, Any], package: str) -> dict[str, Any]:
|
|
35
|
+
"""Walk a FHIR resource dict and replace nested resource dicts with concrete model instances.
|
|
36
|
+
|
|
37
|
+
Intended for use as a model_validator(mode='before') on generic resource containers
|
|
38
|
+
such as Bundle or DomainResource. Processes field values (not the root dict itself) so
|
|
39
|
+
the caller's own Pydantic validation still runs normally.
|
|
40
|
+
"""
|
|
41
|
+
return {k: _preprocess_value(v, package) for k, v in data.items()}
|