@atomic-ehr/codegen 0.0.15 → 0.0.16-canary.20260622073948.8f4b5ac

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 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-sql-on-fhir/), or a [local folder with custom StructureDefinitions](examples/local-package-folder/)
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-profile.test.ts)
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 | no | no | no |
62
- | Profiles | yes | no | no | no |
63
- | Profile Validation | yes | no | no | no |
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 demo and profile usage
120
- - **[typescript-ccda/](examples/typescript-ccda/)** - C-CDA on FHIR type generation
121
- - **[typescript-sql-on-fhir/](examples/typescript-sql-on-fhir/)** - SQL on FHIR ViewDefinition with tree shaking
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
- - **[local-package-folder/](examples/local-package-folder/)** - Loading unpublished local FHIR packages
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("NameSpace", "staticFilesPath") // C# generator
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/](examples/typescript-r4/) for R4 profile tests and [examples/typescript-us-core/](examples/typescript-us-core/) for US Core profile examples.
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
 
@@ -15,6 +15,7 @@ class FhirpyBaseModel(BaseModel):
15
15
  after Pydantic finishes model construction, so that fhirpy can detect it
16
16
  via cls.resourceType for search/fetch operations.
17
17
  """
18
+
18
19
  id: Optional[str] = Field(None, alias="id")
19
20
 
20
21
  @classmethod
@@ -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()}