@atomic-ehr/codegen 0.0.8 → 0.0.9-canary.20260312182458.1c26a02

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
@@ -1,7 +1,7 @@
1
1
  # Atomic EHR Codegen
2
2
 
3
3
  [![npm canary](https://img.shields.io/npm/v/@atomic-ehr/codegen/canary.svg?label=canary)](https://www.npmjs.com/package/@atomic-ehr/codegen/v/canary)
4
- [![npm version](https://badge.fury.io/js/%40atomic-ehr%2Fcodegen.svg)](https://badge.fury.io/js/%40atomic-ehr%2Fcodegen)
4
+ [![npm version](https://img.shields.io/npm/v/@atomic-ehr/codegen.svg)](https://www.npmjs.com/package/@atomic-ehr/codegen)
5
5
  [![CI](https://github.com/atomic-ehr/codegen/actions/workflows/ci.yml/badge.svg)](https://github.com/atomic-ehr/codegen/actions/workflows/ci.yml)
6
6
  [![SDK Tests](https://github.com/atomic-ehr/codegen/actions/workflows/sdk-tests.yml/badge.svg)](https://github.com/atomic-ehr/codegen/actions/workflows/sdk-tests.yml)
7
7
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  - [Atomic EHR Codegen](#atomic-ehr-codegen)
12
12
  - [Features](#features)
13
+ - [Guides](#guides)
13
14
  - [Versions & Release Cycle](#versions--release-cycle)
14
15
  - [Installation](#installation)
15
16
  - [Quick Start](#quick-start)
@@ -20,31 +21,24 @@
20
21
  - [Intermediate - Type Schema](#intermediate---type-schema)
21
22
  - [Tree Shaking](#tree-shaking)
22
23
  - [Field-Level Tree Shaking](#field-level-tree-shaking)
24
+ - [Logical Model Promotion](#logical-model-promotion)
23
25
  - [Generation](#generation)
24
26
  - [1. Writer-Based Generation (Programmatic)](#1-writer-based-generation-programmatic)
25
27
  - [2. Mustache Template-Based Generation (Declarative)](#2-mustache-template-based-generation-declarative)
28
+ - [Profile Classes](#profile-classes)
26
29
  - [Support](#support)
27
- - [Footnotes](#footnotes)
28
30
 
29
31
  <!-- markdown-toc end -->
30
32
 
31
33
  A powerful, extensible code generation toolkit for FHIR ([Fast Healthcare Interoperability Resources](https://www.hl7.org/fhir/)) that transforms FHIR specifications into strongly-typed code for multiple programming languages.
32
34
 
33
- Guides:
34
-
35
- - **[Writer Generator Guide](docs/guides/writer-generator.md)** - Build custom code generators with the Writer base class
36
- - **[Mustache Generator Guide](docs/guides/mustache-generator.md)** - Template-based code generation for any language
37
- - **[TypeSchemaIndex Guide](docs/guides/typeschema-index.md)** - Type Schema structure and utilities
38
- - **[Testing Generators Guide](docs/guides/testing-generators.md)** - Unit tests, snapshot testing, and best practices
39
- - **[Contributing Guide](CONTRIBUTING.md)** - Development setup and workflow
40
-
41
35
  ## Features
42
36
 
43
37
  - [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/)
44
38
  - Tested with hl7.fhir.r4.core, US Core, C-CDA, SQL on FHIR, etc.
45
39
  - [x] **Resources & Complex Types** — Generates typed definitions with proper inheritance
46
40
  - [x] **Value Set Bindings** — Strongly-typed enums from FHIR terminology bindings
47
- - [x] **Profiles & Extensions** — 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/))
41
+ - [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/))
48
42
  - Extensions — flat typed accessors (e.g. `setRace()` on US Core Patient), [standalone extension profiles](examples/typescript-r4/extension-profile.test.ts)
49
43
  - Slicing — typed get/set accessors with discriminator matching
50
44
  - Validation — runtime `validate()` for required fields, fixed values, slice cardinality, enums, references
@@ -52,20 +46,27 @@ Guides:
52
46
  - TypeSchema is a universal intermediate representation — add a new language by writing only the final generation stage
53
47
  - Built-in generators: TypeScript, Python/Pydantic, C#, and Mustache templates
54
48
  - [x] **TypeSchema Transformations**:
55
- - [x] Tree Shaking — include only the resources and fields you need; automatically resolves dependencies
56
- - [x] Logical Model Promotion — promote FHIR logical models (e.g. CDA ClinicalDocument) to first-class resources
57
- - [ ] Renaming — custom naming conventions for generated types, fields, packages, etc.
49
+ - [x] **Tree Shaking** — include only the resources and fields you need; automatically resolves dependencies
50
+ - [x] **Logical Model Promotion** — promote FHIR logical models to first-class resources
51
+ - [ ] Renaming — custom naming conventions for generated types and fields
58
52
  - [ ] **Search Builders** — type-safe FHIR search query construction
59
53
  - [ ] **Operation Generation** — type-safe FHIR operation calls
60
54
 
61
- | Feature | TypeSchema | TypeScript | Python | C# | Mustache |
62
- |---|---|---|---|---|---|
63
- | Resources & Complex Types | yes | yes | yes | yes | template |
64
- | Value Set Bindings | yes | inline | inline | enum | template |
65
- | Profiles & Extensions | yes | yes | no | no | no |
66
- | Tree Shaking | yes | | | | 〃 |
67
- | Logical Model Promotion | yes | | | | 〃 |
55
+ | Feature | TypeScript | Python | C# | Mustache |
56
+ |---------------------------|------------|---------|------|----------|
57
+ | Resources & Complex Types | yes | yes | yes | template |
58
+ | Value Set Bindings | inline | limited | enum | template |
59
+ | Primitive Extensions | yes | no | no | no |
60
+ | Profiles | yes | no | no | no |
61
+ | Profile Validation | yes | no | no | no |
68
62
 
63
+ ## Guides
64
+
65
+ - **[Writer Generator Guide](docs/guides/writer-generator.md)** - Build custom code generators with the Writer base class
66
+ - **[Mustache Generator Guide](docs/guides/mustache-generator.md)** - Template-based code generation for any language
67
+ - **[TypeSchemaIndex Guide](docs/guides/typeschema-index.md)** - Type Schema structure and utilities
68
+ - **[Testing Generators Guide](docs/guides/testing-generators.md)** - Unit tests, snapshot testing, and best practices
69
+ - **[Contributing Guide](CONTRIBUTING.md)** - Development setup and workflow
69
70
 
70
71
  ## Versions & Release Cycle
71
72
 
@@ -204,7 +205,7 @@ Use the new `localPackage` helper to point the builder at an on-disk FHIR packag
204
205
  .localTgzPackage("./packages/my-custom-ig.tgz")
205
206
  ```
206
207
 
207
- The example above points Canonical Manager at `./custom-profiles`, installs the HL7 R4 core dependency automatically, and then limits generation to the custom `ExampleNotebook` logical model plus the standard R4 `Patient` resource via tree shaking. The `localTgzPackage` helper registers `.tgz` artifacts that Canonical Manager already knows how to unpack.
208
+ The example above points Canonical Manager at `./custom-profiles` and installs the HL7 R4 core dependency automatically. The `localTgzPackage` helper registers `.tgz` artifacts that Canonical Manager already knows how to unpack.
208
209
 
209
210
  ### Intermediate - Type Schema
210
211
 
@@ -263,7 +264,7 @@ Beyond resource-level filtering, tree shaking supports fine-grained field select
263
264
 
264
265
  FHIR choice types (like `multipleBirth[x]` which can be boolean or integer) are handled intelligently. Selecting/ignoring the base field affects all variants, while targeting specific variants only affects those types.
265
266
 
266
- ##### Logical Promotion
267
+ #### Logical Model Promotion
267
268
 
268
269
  Some implementation guides expose logical models (logical-kind StructureDefinitions) that are intended to be used like resources in generated SDKs. The code generator supports promoting selected logical models to behave as resources during generation.
269
270
 
@@ -291,9 +292,7 @@ For languages with built-in support (TypeScript, Python, C#), extend the `Writer
291
292
 
292
293
  - **FileSystemWriter**: Base class providing file I/O, directory management, and buffer handling (both disk and in-memory modes)
293
294
  - **Writer**: Extends FileSystemWriter with code formatting utilities (indentation, blocks, comments, line management)
294
- - **Language Writers** (`TypeScript`, `Python`[^py], `CSharp`): Implement language-specific generation logic by traversing TypeSchema index and generating corresponding types, interfaces, or classes
295
-
296
- [^py]: For details on [Type Schema: Python SDK for FHIR](https://www.health-samurai.io/articles/type-schema-python-sdk-for-fhir)
295
+ - **Language Writers** (`TypeScript`, `Python`, `CSharp`): Implement language-specific generation logic by traversing TypeSchema index and generating corresponding types, interfaces, or classes (see also: [Type Schema: Python SDK for FHIR](https://www.health-samurai.io/articles/type-schema-python-sdk-for-fhir))
297
296
 
298
297
  Each language writer maintains full control over output formatting while leveraging high-level abstractions for common code patterns. Writers follow language idioms and best practices, with optimized output for production use.
299
298
 
@@ -344,11 +343,11 @@ const obs = bp.toResource();
344
343
  **Slicing & Choice Type Flattening:**
345
344
 
346
345
  ```typescript
347
- // Simplified getter — discriminator stripped, choice type flattened
348
- bp.getSystolicBP(); // { value: 120, unit: "mmHg" }
346
+ // Flat getter (default) — discriminator stripped, choice type flattened
347
+ bp.getSystolicBP(); // { value: 120, unit: "mmHg" }
349
348
 
350
349
  // Raw getter — full FHIR element including discriminator values
351
- bp.getSystolicBPRaw(); // { code: { coding: [...] }, valueQuantity: { value: 120, ... } }
350
+ bp.getSystolicBP('raw'); // { code: { coding: [...] }, valueQuantity: { value: 120, ... } }
352
351
  ```
353
352
 
354
353
  **Wrapping Existing Resources:**
@@ -373,8 +372,8 @@ See [examples/typescript-r4/](examples/typescript-r4/) for R4 profile tests and
373
372
 
374
373
  ## Support
375
374
 
376
- - 🐛 [Issue Tracker](https://github.com/atomic-ehr/codegen/issues)
375
+ - [Issue Tracker](https://github.com/atomic-ehr/codegen/issues)
377
376
 
378
377
  ---
379
378
 
380
- Built with ❤️ by the Atomic Healthcare team
379
+ Built by the Atomic Healthcare team
@@ -1,4 +1,4 @@
1
- from typing import Any, ClassVar, Type, Union, Optional, Iterator, Tuple, Dict
1
+ from typing import Any, Union, Optional, Iterator, Tuple, Dict
2
2
  from pydantic import BaseModel, Field
3
3
  from typing import Protocol
4
4
 
@@ -8,23 +8,21 @@ class ResourceProtocol(Protocol):
8
8
  id: Union[str, None]
9
9
 
10
10
 
11
- class ResourceTypeDescriptor:
12
- def __get__(self, instance: Optional[BaseModel], owner: Type[BaseModel]) -> str:
13
- field = owner.model_fields.get("resource_type")
14
- if field is None:
15
- raise ValueError("resource_type field not found")
16
- if field.default is None:
17
- raise ValueError("resource_type field default value is not set")
18
- return str(field.default)
19
-
20
-
21
11
  class FhirpyBaseModel(BaseModel):
22
12
  """
23
- This class satisfies ResourceProtocol
13
+ This class satisfies ResourceProtocol.
14
+ Uses __pydantic_init_subclass__ to set resourceType as a class-level attribute
15
+ after Pydantic finishes model construction, so that fhirpy can detect it
16
+ via cls.resourceType for search/fetch operations.
24
17
  """
25
18
  id: Optional[str] = Field(None, alias="id")
26
19
 
27
- resourceType: ClassVar[ResourceTypeDescriptor] = ResourceTypeDescriptor()
20
+ @classmethod
21
+ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
22
+ super().__pydantic_init_subclass__(**kwargs)
23
+ field = cls.model_fields.get("resource_type") or cls.model_fields.get("resourceType")
24
+ if field is not None and field.default is not None:
25
+ type.__setattr__(cls, "resourceType", str(field.default))
28
26
 
29
27
  def __iter__(self) -> Iterator[Tuple[str, Any]]: # type: ignore[override]
30
28
  data = self.model_dump(mode='json', by_alias=True, exclude_none=True)
@@ -10,10 +10,20 @@ class ResourceProtocol(Protocol):
10
10
 
11
11
  class FhirpyBaseModel(BaseModel):
12
12
  """
13
- This class satisfies ResourceProtocol
13
+ This class satisfies ResourceProtocol.
14
+ Uses __pydantic_init_subclass__ to set resourceType as a class-level attribute
15
+ after Pydantic finishes model construction, so that fhirpy can detect it
16
+ via cls.resourceType for search/fetch operations.
14
17
  """
15
18
  id: Optional[str] = Field(None, alias="id")
16
19
 
20
+ @classmethod
21
+ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
22
+ super().__pydantic_init_subclass__(**kwargs)
23
+ field = cls.model_fields.get("resource_type") or cls.model_fields.get("resourceType")
24
+ if field is not None and field.default is not None:
25
+ type.__setattr__(cls, "resourceType", str(field.default))
26
+
17
27
  def __iter__(self) -> Iterator[Tuple[str, Any]]: # type: ignore[override]
18
28
  data = self.model_dump(mode='json', by_alias=True, exclude_none=True)
19
29
  return iter(data.items())
@@ -1,5 +1,7 @@
1
- requests>=2.32.0,<3.0.0
2
- pytest>=8.3.0,<9.0.0
3
- pydantic>=2.11.0,<3.0.0
1
+ fhirpy>=2.0.0,<3.0.0
4
2
  mypy>=1.9.0,<2.0.0
5
- types-requests>=2.32.0,<3.0.0
3
+ pydantic>=2.11.0,<3.0.0
4
+ pytest>=8.3.0,<9.0.0
5
+ pytest-asyncio>=0.24.0,<1.0.0
6
+ requests>=2.32.0,<3.0.0
7
+ types-requests>=2.32.0,<3.0.0
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Runtime helpers for generated FHIR profile classes.
3
+ *
4
+ * This file is copied verbatim into every generated TypeScript output and
5
+ * imported by profile modules. It provides:
6
+ *
7
+ * - **Slice helpers** – match, get, set, and default-fill array slices
8
+ * defined by a FHIR StructureDefinition.
9
+ * - **Extension helpers** – read complex (nested) FHIR extensions into
10
+ * plain objects.
11
+ * - **Choice-type helpers** – wrap/unwrap polymorphic `value[x]` fields so
12
+ * profile classes can expose a flat API.
13
+ * - **Validation helpers** – lightweight structural checks that profile
14
+ * classes call from their `validate()` method.
15
+ * - **Misc utilities** – deep-match, deep-merge, path navigation.
16
+ */
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // General utilities
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Type guard: `value` is a non-null, non-array plain object. */
23
+ export const isRecord = (value: unknown): value is Record<string, unknown> => {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value);
25
+ };
26
+
27
+ /**
28
+ * Walk `path` segments from `root`, creating intermediate objects (or using
29
+ * the first element of an existing array) as needed. Returns the leaf object.
30
+ *
31
+ * Used by extension setters to reach a nested target inside a resource.
32
+ *
33
+ * @example
34
+ * ensurePath(resource, ["contact", "telecom"])
35
+ * // → resource.contact.telecom (created if absent)
36
+ */
37
+ export const ensurePath = (root: Record<string, unknown>, path: string[]): Record<string, unknown> => {
38
+ let current: Record<string, unknown> = root;
39
+ for (const segment of path) {
40
+ if (Array.isArray(current[segment])) {
41
+ const list = current[segment] as unknown[];
42
+ if (list.length === 0) {
43
+ list.push({});
44
+ }
45
+ current = list[0] as Record<string, unknown>;
46
+ } else {
47
+ if (!isRecord(current[segment])) {
48
+ current[segment] = {};
49
+ }
50
+ current = current[segment] as Record<string, unknown>;
51
+ }
52
+ }
53
+ return current;
54
+ };
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Deep match / merge
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Deep-merge `match` into `target`, mutating `target` in place.
62
+ * Skips prototype-pollution keys. Used internally by {@link applySliceMatch}.
63
+ */
64
+ export const mergeMatch = (target: Record<string, unknown>, match: Record<string, unknown>): void => {
65
+ for (const [key, matchValue] of Object.entries(match)) {
66
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
67
+ continue;
68
+ }
69
+ if (isRecord(matchValue)) {
70
+ if (isRecord(target[key])) {
71
+ mergeMatch(target[key] as Record<string, unknown>, matchValue);
72
+ } else {
73
+ target[key] = { ...matchValue };
74
+ }
75
+ } else {
76
+ target[key] = matchValue;
77
+ }
78
+ }
79
+ };
80
+
81
+ /**
82
+ * Shallow-clone `input` then deep-merge the slice discriminator values from
83
+ * `match` on top, returning a complete slice element ready for insertion.
84
+ *
85
+ * @example
86
+ * applySliceMatch({ text: "hi" }, { coding: { code: "vital-signs", system: "…" } })
87
+ * // → { text: "hi", coding: { code: "vital-signs", system: "…" } }
88
+ */
89
+ export const applySliceMatch = <T extends object>(input: Partial<T>, match: Partial<Record<keyof T, unknown>>): T => {
90
+ const result = { ...input } as Record<string, unknown>;
91
+ mergeMatch(result, match);
92
+ return result as T;
93
+ };
94
+
95
+ /**
96
+ * Recursively test whether `value` structurally contains everything in
97
+ * `match`. Arrays are matched with "every match item has a corresponding
98
+ * value item" semantics; objects are matched key-by-key; primitives use `===`.
99
+ *
100
+ * This is the core discriminator check used to identify which array element
101
+ * belongs to a given FHIR slice.
102
+ */
103
+ export const matchesValue = (value: unknown, match: unknown): boolean => {
104
+ if (Array.isArray(match)) {
105
+ if (!Array.isArray(value)) {
106
+ return false;
107
+ }
108
+ return match.every((matchItem) => value.some((item) => matchesValue(item, matchItem)));
109
+ }
110
+ if (isRecord(match)) {
111
+ if (!isRecord(value)) {
112
+ return false;
113
+ }
114
+ for (const [key, matchValue] of Object.entries(match)) {
115
+ if (!matchesValue((value as Record<string, unknown>)[key], matchValue)) {
116
+ return false;
117
+ }
118
+ }
119
+ return true;
120
+ }
121
+ return value === match;
122
+ };
123
+
124
+ /**
125
+ * Type guard that discriminates a raw extension input (with an `extension`
126
+ * array) from a flat-API input object. Using a custom type guard instead of
127
+ * a bare `"extension" in args` lets TypeScript narrow *both* branches of the
128
+ * union — the plain `in` check cannot eliminate a type whose `extension`
129
+ * property is optional.
130
+ */
131
+ export const isRawExtensionInput = <TRaw extends object>(input: object): input is TRaw => "extension" in input;
132
+
133
+ /**
134
+ * Type guard that tests whether an unknown setter input is a raw Extension
135
+ * (i.e. an object with a `url` property). When `url` is provided, also
136
+ * checks that the extension's URL matches the expected value.
137
+ */
138
+ export const isExtension = <E extends { url: string }>(input: unknown, url?: string): input is E =>
139
+ typeof input === "object" && input !== null && "url" in input && (url === undefined || input.url === url);
140
+
141
+ /**
142
+ * Read a single typed value field from an Extension, returning `undefined`
143
+ * when the extension itself is absent or the field is not set.
144
+ *
145
+ * This avoids the double-cast `(ext as Record<…>)?.field as T` that would
146
+ * otherwise be needed for value fields not declared on the base Extension type.
147
+ */
148
+ export const getExtensionValue = <T>(ext: { url?: string } | undefined, field: string): T | undefined => {
149
+ if (!ext) return undefined;
150
+ return (ext as Record<string, unknown>)[field] as T | undefined;
151
+ };
152
+
153
+ /**
154
+ * Push an extension onto `target.extension`, creating the array if absent.
155
+ */
156
+ export const pushExtension = <E extends { url?: string }>(target: { extension?: E[] }, ext: E): void => {
157
+ (target.extension ??= []).push(ext);
158
+ };
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Extension helpers
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Read a complex (nested) FHIR extension into a plain key/value object.
166
+ *
167
+ * Each entry in `config` describes one sub-extension by URL, the name of its
168
+ * value field (e.g. `"valueString"`), and whether it may repeat.
169
+ *
170
+ * @returns A record keyed by sub-extension URL, or `undefined` if the
171
+ * extension has no nested children.
172
+ */
173
+ export const extractComplexExtension = <T = Record<string, unknown>>(
174
+ extension: { extension?: Array<{ url?: string }> } | undefined,
175
+ config: Array<{ name: string; valueField: string; isArray: boolean }>,
176
+ ): T | undefined => {
177
+ if (!extension?.extension) return undefined;
178
+ const result: Record<string, unknown> = {};
179
+ for (const { name, valueField, isArray } of config) {
180
+ const subExts = extension.extension.filter((e) => e.url === name);
181
+ if (isArray) {
182
+ result[name] = subExts.map((e) => (e as Record<string, unknown>)[valueField]);
183
+ } else if (subExts[0]) {
184
+ result[name] = (subExts[0] as Record<string, unknown>)[valueField];
185
+ }
186
+ }
187
+ return result as T;
188
+ };
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Slice helpers
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Remove discriminator keys from a slice element, returning only the
196
+ * user-supplied portion. Used by slice getters so callers see a clean object
197
+ * without the fixed discriminator values baked in.
198
+ */
199
+ export const stripMatchKeys = <T>(slice: object, matchKeys: string[]): T => {
200
+ const result = { ...slice } as Record<string, unknown>;
201
+ for (const key of matchKeys) {
202
+ delete result[key];
203
+ }
204
+ return result as T;
205
+ };
206
+
207
+ /**
208
+ * Wrap a flat input object under a choice-type key before inserting into a
209
+ * slice. For example, a Quantity value destined for `valueQuantity` is
210
+ * wrapped as `{ valueQuantity: { ...input } }`.
211
+ *
212
+ * No-op when `input` is empty (the slice will contain only discriminator
213
+ * defaults).
214
+ */
215
+ export const wrapSliceChoice = <T>(input: object, choiceVariant: string): Partial<T> => {
216
+ if (Object.keys(input).length === 0) return input as Partial<T>;
217
+ return { [choiceVariant]: input } as Partial<T>;
218
+ };
219
+
220
+ /**
221
+ * Inverse of {@link wrapSliceChoice}: strip discriminator keys, then hoist
222
+ * the value inside `choiceVariant` up to the top level.
223
+ *
224
+ * @example
225
+ * unwrapSliceChoice(raw, ["code"], "valueQuantity")
226
+ * // removes "code", moves raw.valueQuantity.* to top level
227
+ */
228
+ export const unwrapSliceChoice = <T>(slice: object, matchKeys: string[], choiceVariant: string): T => {
229
+ const result = { ...slice } as Record<string, unknown>;
230
+ for (const key of matchKeys) {
231
+ delete result[key];
232
+ }
233
+ const variantValue = result[choiceVariant];
234
+ delete result[choiceVariant];
235
+ if (isRecord(variantValue)) {
236
+ Object.assign(result, variantValue);
237
+ }
238
+ return result as T;
239
+ };
240
+
241
+ /**
242
+ * Ensure that every required slice has at least a stub element in the array.
243
+ * Each `match` is a discriminator pattern; if no existing item satisfies it,
244
+ * a deep clone of the pattern is appended.
245
+ *
246
+ * Called in `createResource` so that required slices are always present even
247
+ * when the caller omits them.
248
+ */
249
+ export const ensureSliceDefaults = <T>(items: T[], ...matches: Record<string, unknown>[]): T[] => {
250
+ for (const match of matches) {
251
+ if (!items.some((item) => matchesValue(item, match))) {
252
+ items.push(structuredClone(match) as T);
253
+ }
254
+ }
255
+ return items;
256
+ };
257
+
258
+ /**
259
+ * Cast an object literal to a FHIR resource type. This centralises the single
260
+ * `as unknown as T` that is unavoidable when constructing a resource from a
261
+ * plain object (deep inheritance prevents direct structural compatibility).
262
+ */
263
+ export const buildResource = <T>(obj: object): T => obj as unknown as T;
264
+
265
+ /**
266
+ * Add `canonicalUrl` to `resource.meta.profile` if not already present.
267
+ * Creates `meta` and `profile` when missing.
268
+ */
269
+ export const ensureProfile = (resource: { meta?: { profile?: string[] } }, canonicalUrl: string): void => {
270
+ const meta = (resource.meta ??= {});
271
+ const profiles = (meta.profile ??= []);
272
+ if (!profiles.includes(canonicalUrl)) profiles.push(canonicalUrl);
273
+ };
274
+
275
+ /**
276
+ * Find or insert a slice element in `list`. If an element matching `match`
277
+ * already exists it is replaced in place; otherwise `value` is appended.
278
+ */
279
+ export const setArraySlice = <T>(list: T[], match: Record<string, unknown>, value: T): void => {
280
+ const index = list.findIndex((item) => matchesValue(item, match));
281
+ if (index === -1) {
282
+ list.push(value);
283
+ } else {
284
+ list[index] = value;
285
+ }
286
+ };
287
+
288
+ /** Return the first element in `list` that satisfies the slice discriminator `match`. */
289
+ export const getArraySlice = <T>(list: readonly T[] | undefined, match: Record<string, unknown>): T | undefined => {
290
+ if (!list) return undefined;
291
+ return list.find((item) => matchesValue(item, match));
292
+ };
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Validation helpers
296
+ //
297
+ // Each function returns an array of human-readable error strings (empty = ok).
298
+ // Profile classes spread them all into a single array from `validate()`.
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /** Checks that `field` is present (not `undefined` or `null`). */
302
+ export const validateRequired = (res: object, profileName: string, field: string): string[] => {
303
+ const rec = res as Record<string, unknown>;
304
+ return rec[field] === undefined || rec[field] === null
305
+ ? [`${profileName}: required field '${field}' is missing`]
306
+ : [];
307
+ };
308
+
309
+ /** Checks that a must-support field is populated (warning, not error). */
310
+ export const validateMustSupport = (res: object, profileName: string, field: string): string[] => {
311
+ const rec = res as Record<string, unknown>;
312
+ return rec[field] === undefined || rec[field] === null
313
+ ? [`${profileName}: must-support field '${field}' is not populated`]
314
+ : [];
315
+ };
316
+
317
+ /** Checks that `field` is absent (profiles may exclude base fields). */
318
+ export const validateExcluded = (res: object, profileName: string, field: string): string[] => {
319
+ return (res as Record<string, unknown>)[field] !== undefined
320
+ ? [`${profileName}: field '${field}' must not be present`]
321
+ : [];
322
+ };
323
+
324
+ /** Checks that `field` structurally contains the expected fixed value. */
325
+ export const validateFixedValue = (res: object, profileName: string, field: string, expected: unknown): string[] => {
326
+ return matchesValue((res as Record<string, unknown>)[field], expected)
327
+ ? []
328
+ : [`${profileName}: field '${field}' does not match expected fixed value`];
329
+ };
330
+
331
+ /**
332
+ * Checks that the number of array elements matching `match` (a slice
333
+ * discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded.
334
+ */
335
+ export const validateSliceCardinality = (
336
+ res: object,
337
+ profileName: string,
338
+ field: string,
339
+ match: Record<string, unknown>,
340
+ sliceName: string,
341
+ min: number,
342
+ max: number,
343
+ ): string[] => {
344
+ const items = (res as Record<string, unknown>)[field] as unknown[] | undefined;
345
+ const count = (items ?? []).filter((item) => matchesValue(item, match)).length;
346
+ const errors: string[] = [];
347
+ if (count < min) {
348
+ errors.push(`${profileName}.${field}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`);
349
+ }
350
+ if (max > 0 && count > max) {
351
+ errors.push(`${profileName}.${field}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`);
352
+ }
353
+ return errors;
354
+ };
355
+
356
+ /**
357
+ * Checks that at least one of the listed choice-type variants is present.
358
+ * E.g. `["effectiveDateTime", "effectivePeriod"]`.
359
+ */
360
+ export const validateChoiceRequired = (res: object, profileName: string, choices: string[]): string[] => {
361
+ const rec = res as Record<string, unknown>;
362
+ return choices.some((c) => rec[c] !== undefined)
363
+ ? []
364
+ : [`${profileName}: at least one of ${choices.join(", ")} is required`];
365
+ };
366
+
367
+ /**
368
+ * Checks that the value of `field` has a code within `allowed`.
369
+ * Handles plain strings, Coding objects, and CodeableConcept objects.
370
+ * Skips validation when the field is absent.
371
+ */
372
+ export const validateEnum = (res: object, profileName: string, field: string, allowed: string[]): string[] => {
373
+ const value = (res as Record<string, unknown>)[field];
374
+ if (value === undefined || value === null) return [];
375
+ if (typeof value === "string") {
376
+ return allowed.includes(value)
377
+ ? []
378
+ : [`${profileName}: field '${field}' value '${value}' is not in allowed values`];
379
+ }
380
+ const rec = value as Record<string, unknown>;
381
+ // Coding
382
+ if (typeof rec.code === "string" && rec.system !== undefined) {
383
+ return allowed.includes(rec.code)
384
+ ? []
385
+ : [`${profileName}: field '${field}' code '${rec.code}' is not in allowed values`];
386
+ }
387
+ // CodeableConcept
388
+ if (Array.isArray(rec.coding)) {
389
+ const codes = (rec.coding as Record<string, unknown>[]).map((c) => c.code as string).filter(Boolean);
390
+ const hasValid = codes.some((c) => allowed.includes(c));
391
+ return hasValid ? [] : [`${profileName}: field '${field}' has no coding with an allowed code`];
392
+ }
393
+ return [];
394
+ };
395
+
396
+ /**
397
+ * Checks that a Reference field points to one of the `allowed` resource
398
+ * types. Extracts the type from the `reference` string (the part before
399
+ * the first `/`). Skips validation when the field or reference is absent.
400
+ */
401
+ export const validateReference = (res: object, profileName: string, field: string, allowed: string[]): string[] => {
402
+ const value = (res as Record<string, unknown>)[field];
403
+ if (value === undefined || value === null) return [];
404
+ const ref = (value as Record<string, unknown>).reference as string | undefined;
405
+ if (!ref) return [];
406
+ const slashIdx = ref.indexOf("/");
407
+ if (slashIdx === -1) return [];
408
+ const refType = ref.slice(0, slashIdx);
409
+ return allowed.includes(refType)
410
+ ? []
411
+ : [`${profileName}: field '${field}' references '${refType}' but only ${allowed.join(", ")} are allowed`];
412
+ };