@atomic-ehr/codegen 0.0.18 → 0.0.19-canary.20260921150939.55823d6
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 +71 -0
- package/assets/api/writer-generator/python/profile_helpers.py +29 -1
- package/assets/api/writer-generator/typescript/profile-helpers.ts +66 -4
- package/dist/cli/index.js +21 -10
- package/dist/index.d.ts +108 -14
- package/dist/index.js +1355 -608
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -113,6 +113,58 @@ yarn add @atomic-ehr/codegen
|
|
|
113
113
|
- `bun run generate-types.ts`
|
|
114
114
|
- `pnpm exec tsx generate-types.ts`
|
|
115
115
|
|
|
116
|
+
Alternatively, drive the same pipeline from a JSON config file, with no script at all:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"version": 1,
|
|
121
|
+
"builders": [
|
|
122
|
+
{
|
|
123
|
+
"name": "core",
|
|
124
|
+
"fromPackages": [{ "name": "hl7.fhir.r4.core", "version": "4.0.1" }],
|
|
125
|
+
"typescript": {},
|
|
126
|
+
"outputTo": "./fhir-types"
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
atomic-codegen generate --config ./codegen.json # run every builder
|
|
134
|
+
atomic-codegen generate --config ./codegen.json --dry-run # print the plan without generating
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Relative paths resolve against the config file's directory, unknown keys are rejected with the full list of problems, and `outputTo` is removed before generation by default (set `"cleanOutput": false` to keep it). A config may hold several builders: each maps to one `APIBuilder` pipeline (`fromPackages`/`fromPackageRefs`/`localTgzPackages`/`localStructureDefinitions` inputs, `typeSchema` transformations, and `typescript`/`python`/`csharp`/`introspection` generators — see `GenerateConfigBuilder` in `src/api/generate-config.ts`). A failed builder does not stop the others, and the run exits non-zero if any failed.
|
|
138
|
+
|
|
139
|
+
The JSON `typescript` options also accept `moduleSpecifierStyle` (`"extensionless"` or `"node-esm"`) and `terminology`. For Node ESM imports and terminology output restricted to selected packages, replace `"typescript": {}` with:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
"typescript": {
|
|
143
|
+
"moduleSpecifierStyle": "node-esm",
|
|
144
|
+
"terminology": {
|
|
145
|
+
"enabled": true,
|
|
146
|
+
"packages": ["hl7.fhir.r4.core@4.0.1"],
|
|
147
|
+
"packageVerification": {
|
|
148
|
+
"hl7.fhir.r4.core@4.0.1": "registry-integrity"
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
All terminology fields are optional. When supplied, `enabled` must be a boolean, `packages` must be an array of strings, and `packageVerification` must contain string values. Unknown nested keys and invalid values are reported with their full configuration paths.
|
|
155
|
+
|
|
156
|
+
### Fixing defective packages
|
|
157
|
+
|
|
158
|
+
Real FHIR packages ship defects — missing dependencies, typo'd names and canonicals, bindings to unavailable ValueSets, incomplete CodeSystems. Fixes are declared, not hand-coded:
|
|
159
|
+
|
|
160
|
+
- **Canonical exclusions** — drop a known-broken canonical at the package index, before codegen sees it: `canonicalManager: { patches: { indexEntry: [excludeCanonical({ package, url, reason })] } }`. Codegen ships `builtinPatches` (`src/api/builtin-patches.ts` — generation-breaking content in HL7's own packages, e.g. `hl7.fhir.r5.core` profiles that break R4-compatible generation); they apply to every loader the builder constructs, on top of your own patches, and `builtinPatches: false` is the explicit opt-out. Index exclusion removes the canonical from resolution too, so exclude whole derivation chains together — and it is applied at scan time, so drop the loader cache after changing the list. A hand-built CanonicalManager owns its wiring: apply `builtinPatches.indexEntry` explicitly (see the ccda example).
|
|
161
|
+
- **Custom patches** — per-phase handlers built from the helpers on the `@atomic-ehr/fhir-canonical-manager/patch` subpath (`ensureDependency`, `renamePackage`, `replaceText`, `ensureCodes`, scoped by `inPackage`/`inResource`), passed as `new APIBuilder({ canonicalManager: { patches: {...} } })`. In the CLI config, `"options": { "forceDependencies": {...} }` pins declared dependency versions across the closure.
|
|
162
|
+
- **Broken package index** — `canonicalManager: { packageIndex: "recover" }` heals a corrupt `.index.json` by falling back to a directory scan (always with a warning); `"regenerate"` ignores the shipped index entirely.
|
|
163
|
+
|
|
164
|
+
Loader settings (`registry`, `packageIndex`, `dropCache`, `patches`) live under the builder's `canonicalManager` option — they configure the CanonicalManager package loader, not the generator. The old flat options are deprecated and warn.
|
|
165
|
+
|
|
166
|
+
Applied fixes surface in the generation report's "Input fixes" section.
|
|
167
|
+
|
|
116
168
|
### Usage Examples
|
|
117
169
|
|
|
118
170
|
See the [examples/](examples/) directory for working demonstrations:
|
|
@@ -157,6 +209,12 @@ const builder = new APIBuilder()
|
|
|
157
209
|
generateProfile?: boolean,
|
|
158
210
|
withDebugComment?: boolean,
|
|
159
211
|
openResourceTypeSet?: boolean,
|
|
212
|
+
terminology?: {
|
|
213
|
+
enabled: true,
|
|
214
|
+
packageVerification: {
|
|
215
|
+
"hl7.fhir.r4.core@4.0.1": "registry-integrity",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
160
218
|
})
|
|
161
219
|
.python({ // Python generator
|
|
162
220
|
client?: "fhirpy" | "none", // client integration (default: fhirpy)
|
|
@@ -204,6 +262,10 @@ Each language generator accepts its own option object. All options are optional;
|
|
|
204
262
|
| `sliceGetterDefault` | `"flat" \| "raw"` | `"flat"` | Default return shape for generated slice getters (`flat` strips discriminators, `raw` returns the full FHIR element). |
|
|
205
263
|
| `lineWidth` | `number` | `120` | Maximum line width before wrapping. |
|
|
206
264
|
| `withDebugComment` | `boolean` | `false` | Emit comments tracing each generated type back to its source schema. |
|
|
265
|
+
| `terminology.enabled` | `boolean` | `false` | Emit a `terminology.ts` module for every package in the resolved closure. |
|
|
266
|
+
| `terminology.packageVerification` | `Record<string, string>` | `{}` | Map package references such as `hl7.fhir.r4.core@4.0.1` to a closure verification state (`registry-integrity`, `unverifiable`, ...). Absent entries record `not-recorded`. |
|
|
267
|
+
|
|
268
|
+
When terminology generation is enabled, each exported symbol includes its canonical identity, source package and version, declared content mode, and verification state. Only CodeSystems declaring `content: "complete"` emit code unions and display maps. ValueSet expansions are never promoted to constants, and an `unverifiable` package emits identity and provenance without concept content.
|
|
207
269
|
|
|
208
270
|
**Python** — `.python({ ... })`
|
|
209
271
|
|
|
@@ -441,6 +503,15 @@ Templates enable flexible code generation for any language or format (Go, Rust,
|
|
|
441
503
|
|
|
442
504
|
When generating TypeScript with `generateProfile: true`, the generator creates profile wrapper classes that provide a fluent API for working with FHIR profiles. These classes handle complex profile constraints like slicing and extensions automatically.
|
|
443
505
|
|
|
506
|
+
Resource profile classes expose `static readonly resourceType` alongside `canonicalUrl`, `from()` and `createResource()`. A generic FHIR client can use the class itself as a structural descriptor. The resource type comes from the resolved snapshot base; Extension and datatype profile classes do not expose `resourceType`.
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { observation_bodyweightProfile } from "./profiles/Observation_observation_bodyweight";
|
|
510
|
+
|
|
511
|
+
observation_bodyweightProfile.resourceType; // "Observation"
|
|
512
|
+
observation_bodyweightProfile.canonicalUrl; // "http://hl7.org/fhir/StructureDefinition/bodyweight"
|
|
513
|
+
```
|
|
514
|
+
|
|
444
515
|
```typescript
|
|
445
516
|
import { observation_bpProfile as bpProfile } from "./profiles/Observation_observation_bp";
|
|
446
517
|
|
|
@@ -417,6 +417,20 @@ def validate_fixed_value(res: object, profile_name: str, field: str, expected: o
|
|
|
417
417
|
)
|
|
418
418
|
|
|
419
419
|
|
|
420
|
+
def validate_pattern_value(res: object, profile_name: str, field: str, expected: object) -> list[str]:
|
|
421
|
+
"""Containment constraint for a field that may be absent: absence is
|
|
422
|
+
``validate_required``'s concern, so an absent field passes; a present one
|
|
423
|
+
must structurally contain ``expected``."""
|
|
424
|
+
actual = _get_field(res, field)
|
|
425
|
+
if actual is None:
|
|
426
|
+
return []
|
|
427
|
+
return (
|
|
428
|
+
[]
|
|
429
|
+
if matches_value(actual, expected)
|
|
430
|
+
else [f"{profile_name}: field '{field}' does not match expected pattern"]
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
420
434
|
def validate_slice_cardinality(
|
|
421
435
|
res: object,
|
|
422
436
|
profile_name: str,
|
|
@@ -451,9 +465,15 @@ def validate_slice_fields(
|
|
|
451
465
|
match: Mapping[str, Any],
|
|
452
466
|
slice_name: str,
|
|
453
467
|
required_fields: Sequence[str],
|
|
468
|
+
choice_groups: Sequence[Sequence[str]] = (),
|
|
454
469
|
) -> list[str]:
|
|
455
470
|
"""Validates required fields within matched slice elements. For each array
|
|
456
|
-
item matching the discriminator, checks that the listed fields are present.
|
|
471
|
+
item matching the discriminator, checks that the listed fields are present.
|
|
472
|
+
|
|
473
|
+
``choice_groups`` carries the required choice elements of the slice: each
|
|
474
|
+
group lists the typed variants a single ``value[x]``-style element may take,
|
|
475
|
+
and is satisfied by any one of them. E.g. ``[["valueCoding"]]`` for a slice
|
|
476
|
+
whose ``value[x]`` is required and narrowed to Coding."""
|
|
457
477
|
items = _get_field(res, field) or []
|
|
458
478
|
if not isinstance(items, Iterable):
|
|
459
479
|
items = []
|
|
@@ -464,6 +484,14 @@ def validate_slice_fields(
|
|
|
464
484
|
for rf in required_fields:
|
|
465
485
|
if _get_field(item, rf) is None:
|
|
466
486
|
errors.append(f"{profile_name}.{field}[{slice_name}].{rf} is required")
|
|
487
|
+
for group in choice_groups:
|
|
488
|
+
if any(_get_field(item, variant) is not None for variant in group):
|
|
489
|
+
continue
|
|
490
|
+
errors.append(
|
|
491
|
+
f"{profile_name}.{field}[{slice_name}].{group[0]} is required"
|
|
492
|
+
if len(group) == 1
|
|
493
|
+
else f"{profile_name}.{field}[{slice_name}]: at least one of {', '.join(group)} is required"
|
|
494
|
+
)
|
|
467
495
|
return errors
|
|
468
496
|
|
|
469
497
|
|
|
@@ -369,13 +369,59 @@ export const validateExcluded = (res: object, profileName: string, field: string
|
|
|
369
369
|
: [];
|
|
370
370
|
};
|
|
371
371
|
|
|
372
|
-
/**
|
|
373
|
-
|
|
374
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Shared body for the `fixed[x]`/`pattern[x]` checks, keeping FHIR's two array
|
|
374
|
+
* rules apart:
|
|
375
|
+
*
|
|
376
|
+
* - Across repetitions: a constraint declared on a repeating element applies to
|
|
377
|
+
* all repetitions, so every one of them must match and the element must
|
|
378
|
+
* actually be an array.
|
|
379
|
+
* - Inside one value: arrays nested in the constraint keep `matchesValue`'s
|
|
380
|
+
* "each constraint entry matches at least one instance entry" rule.
|
|
381
|
+
*/
|
|
382
|
+
const matchesConstrainedValue = (value: unknown, expected: unknown, repeating: boolean): boolean =>
|
|
383
|
+
repeating
|
|
384
|
+
? Array.isArray(value) && value.length > 0 && value.every((item) => matchesValue(item, expected))
|
|
385
|
+
: !Array.isArray(value) && matchesValue(value, expected);
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Checks that a present `field` structurally contains the expected fixed value.
|
|
389
|
+
* Absence passes — `fixed[x]` applies "if present", so a missing element is
|
|
390
|
+
* `validateRequired`'s concern. Pass `repeating` for an element with max > 1.
|
|
391
|
+
*/
|
|
392
|
+
export const validateFixedValue = (
|
|
393
|
+
res: object,
|
|
394
|
+
profileName: string,
|
|
395
|
+
field: string,
|
|
396
|
+
expected: unknown,
|
|
397
|
+
repeating = false,
|
|
398
|
+
): string[] => {
|
|
399
|
+
const value = (res as Record<string, unknown>)[field];
|
|
400
|
+
if (value === undefined || value === null) return [];
|
|
401
|
+
return matchesConstrainedValue(value, expected, repeating)
|
|
375
402
|
? []
|
|
376
403
|
: [`${profileName}: field '${field}' does not match expected fixed value`];
|
|
377
404
|
};
|
|
378
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Containment constraint for a field that may be absent: absence is
|
|
408
|
+
* `validateRequired`'s concern, so an absent field passes; a present one must
|
|
409
|
+
* structurally contain `expected` — every repetition, when `repeating`.
|
|
410
|
+
*/
|
|
411
|
+
export const validatePatternValue = (
|
|
412
|
+
res: object,
|
|
413
|
+
profileName: string,
|
|
414
|
+
field: string,
|
|
415
|
+
expected: unknown,
|
|
416
|
+
repeating = false,
|
|
417
|
+
): string[] => {
|
|
418
|
+
const value = (res as Record<string, unknown>)[field];
|
|
419
|
+
if (value === undefined || value === null) return [];
|
|
420
|
+
return matchesConstrainedValue(value, expected, repeating)
|
|
421
|
+
? []
|
|
422
|
+
: [`${profileName}: field '${field}' does not match expected pattern`];
|
|
423
|
+
};
|
|
424
|
+
|
|
379
425
|
/**
|
|
380
426
|
* Checks that the number of array elements matching `match` (a slice
|
|
381
427
|
* discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded.
|
|
@@ -404,6 +450,11 @@ export const validateSliceCardinality = (
|
|
|
404
450
|
/**
|
|
405
451
|
* Validate required fields within matched slice elements.
|
|
406
452
|
* For each array item matching the discriminator, checks that the listed fields are present.
|
|
453
|
+
*
|
|
454
|
+
* `choiceGroups` carries the required choice elements of the slice: each group
|
|
455
|
+
* lists the typed variants a single `value[x]`-style element may take, and is
|
|
456
|
+
* satisfied by any one of them. E.g. `[["valueCodeableConcept"]]` for a slice
|
|
457
|
+
* whose `value[x]` is required and narrowed to CodeableConcept.
|
|
407
458
|
*/
|
|
408
459
|
export const validateSliceFields = (
|
|
409
460
|
res: object,
|
|
@@ -412,16 +463,27 @@ export const validateSliceFields = (
|
|
|
412
463
|
match: Record<string, unknown>,
|
|
413
464
|
sliceName: string,
|
|
414
465
|
requiredFields: string[],
|
|
466
|
+
choiceGroups: string[][] = [],
|
|
415
467
|
): string[] => {
|
|
416
468
|
const items = (res as Record<string, unknown>)[field] as unknown[] | undefined;
|
|
417
469
|
const errors: string[] = [];
|
|
470
|
+
const isPresent = (obj: Record<string, unknown>, key: string): boolean =>
|
|
471
|
+
obj[key] !== undefined && obj[key] !== null;
|
|
418
472
|
for (const item of (items ?? []).filter((item) => matchesValue(item, match))) {
|
|
419
473
|
const obj = item as Record<string, unknown>;
|
|
420
474
|
for (const rf of requiredFields) {
|
|
421
|
-
if (obj
|
|
475
|
+
if (!isPresent(obj, rf)) {
|
|
422
476
|
errors.push(`${profileName}.${field}[${sliceName}].${rf} is required`);
|
|
423
477
|
}
|
|
424
478
|
}
|
|
479
|
+
for (const group of choiceGroups) {
|
|
480
|
+
if (group.some((variant) => isPresent(obj, variant))) continue;
|
|
481
|
+
errors.push(
|
|
482
|
+
group.length === 1
|
|
483
|
+
? `${profileName}.${field}[${sliceName}].${group[0]} is required`
|
|
484
|
+
: `${profileName}.${field}[${sliceName}]: at least one of ${group.join(", ")} is required`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
425
487
|
}
|
|
426
488
|
return errors;
|
|
427
489
|
};
|