diff --git a/CLAUDE.md b/CLAUDE.md index 2eccb3a5..5bfce572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,14 @@ FHIR Package → TypeSchema Generator → TypeSchema Format → Code Generators - If a change is a direct fix for a specific previous commit, place it immediately after that commit with a `fix:` prefix in the message. - Typical commit order: source changes → test changes → regenerated examples. Example updates should be the last commit in the branch. +## Pull Request Style + +- PR body should be a bullet list summarizing changes — no section headers, no test plan. + - Use two-level nesting to group related items when the list is long; keep it flat when short. +- Keep bullets concise and focused on what changed, not why. +- When a PR changes generated code or user-facing API, include before/after code examples. + - Add a short motivation line before each example explaining why the change was made. + ## Development Guidelines ### TypeScript Configuration @@ -198,6 +206,29 @@ Located in `src/api/writer-generator/`: - Each writer traverses TypeSchema index and generates code - Maintains language-specific idioms and conventions +## Static Assets for Generators + +Static files that are copied verbatim into generated output live in `assets/api/writer-generator//`. Each language writer has a resolver function (e.g., `resolveTsAssets`, `resolvePyAssets`) that handles path resolution for both dev (`src/`) and dist (`dist/`) builds. + +**Pattern:** +``` +assets/api/writer-generator/ +├── typescript/profile-helpers.ts # Runtime helpers for TS profile classes +└── python/ + ├── requirements.txt + ├── fhirpy_base_model.py + └── resource_family_validator.py +``` + +**How it works:** +1. Asset files are authored/maintained directly in `assets/` (included in biome linting) +2. Writers copy them to output via `this.cp("filename", "filename")` — uses `Writer.cp()` which resolves via `resolveAssets` +3. Each language writer sets `resolveAssets` in its constructor (e.g., TypeScript writer defaults to `resolveTsAssets`) + +**When to use assets vs programmatic generation:** +- Use assets for static runtime code shared across all generated profiles (helpers, validators, base models) +- Use programmatic generation (`w.lineSM()`, `w.curlyBlock()`) for code that varies per schema/profile + ## Common Development Patterns ### Adding a New Generator Feature @@ -235,10 +266,11 @@ Located in `src/api/writer-generator/`: ### Generators - `src/api/writer-generator/introspection.ts` - TypeSchema introspection generation -- `src/api/writer-generator/typescript.ts` - TypeScript code generation +- `src/api/writer-generator/typescript/writer.ts` - TypeScript type generation +- `src/api/writer-generator/typescript/profile.ts` - TypeScript profile class generation - `src/api/writer-generator/python.ts` - Python/Pydantic generation -- `src/api/writer-generator/csharp.ts` - C# generation -- `src/api/writer-generator/base.ts` - Common writer utilities +- `src/api/writer-generator/csharp/csharp.ts` - C# generation +- `src/api/writer-generator/writer.ts` - Base Writer class (I/O, indentation, `cp()` for assets) ### FHIR Processing - `src/typeschema/register.ts` - Package registration and canonical resolution diff --git a/README.md b/README.md index 48119847..69520613 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - [Atomic EHR Codegen](#atomic-ehr-codegen) - [Features](#features) + - [Guides](#guides) - [Versions & Release Cycle](#versions--release-cycle) - [Installation](#installation) - [Quick Start](#quick-start) @@ -20,31 +21,24 @@ - [Intermediate - Type Schema](#intermediate---type-schema) - [Tree Shaking](#tree-shaking) - [Field-Level Tree Shaking](#field-level-tree-shaking) + - [Logical Model Promotion](#logical-model-promotion) - [Generation](#generation) - [1. Writer-Based Generation (Programmatic)](#1-writer-based-generation-programmatic) - [2. Mustache Template-Based Generation (Declarative)](#2-mustache-template-based-generation-declarative) + - [Profile Classes](#profile-classes) - [Support](#support) -- [Footnotes](#footnotes) 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. -Guides: - -- **[Writer Generator Guide](docs/guides/writer-generator.md)** - Build custom code generators with the Writer base class -- **[Mustache Generator Guide](docs/guides/mustache-generator.md)** - Template-based code generation for any language -- **[TypeSchemaIndex Guide](docs/guides/typeschema-index.md)** - Type Schema structure and utilities -- **[Testing Generators Guide](docs/guides/testing-generators.md)** - Unit tests, snapshot testing, and best practices -- **[Contributing Guide](CONTRIBUTING.md)** - Development setup and workflow - ## Features - [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/) - Tested with hl7.fhir.r4.core, US Core, C-CDA, SQL on FHIR, etc. - [x] **Resources & Complex Types** — Generates typed definitions with proper inheritance - [x] **Value Set Bindings** — Strongly-typed enums from FHIR terminology bindings -- [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/)) +- [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/)) - Extensions — flat typed accessors (e.g. `setRace()` on US Core Patient), [standalone extension profiles](examples/typescript-r4/extension-profile.test.ts) - Slicing — typed get/set accessors with discriminator matching - Validation — runtime `validate()` for required fields, fixed values, slice cardinality, enums, references @@ -52,20 +46,27 @@ Guides: - TypeSchema is a universal intermediate representation — add a new language by writing only the final generation stage - Built-in generators: TypeScript, Python/Pydantic, C#, and Mustache templates - [x] **TypeSchema Transformations**: - - [x] Tree Shaking — include only the resources and fields you need; automatically resolves dependencies - - [x] Logical Model Promotion — promote FHIR logical models (e.g. CDA ClinicalDocument) to first-class resources - - [ ] Renaming — custom naming conventions for generated types, fields, packages, etc. + - [x] **Tree Shaking** — include only the resources and fields you need; automatically resolves dependencies + - [x] **Logical Model Promotion** — promote FHIR logical models to first-class resources + - [ ] Renaming — custom naming conventions for generated types and fields - [ ] **Search Builders** — type-safe FHIR search query construction - [ ] **Operation Generation** — type-safe FHIR operation calls -| Feature | TypeSchema | TypeScript | Python | C# | Mustache | -|---|---|---|---|---|---| -| Resources & Complex Types | yes | yes | yes | yes | template | -| Value Set Bindings | yes | inline | inline | enum | template | -| Profiles & Extensions | yes | yes | no | no | no | -| Tree Shaking | yes | 〃 | 〃 | 〃 | 〃 | -| Logical Model Promotion | yes | 〃 | 〃 | 〃 | 〃 | +| Feature | TypeScript | Python | C# | Mustache | +|---------------------------|------------|---------|------|----------| +| Resources & Complex Types | yes | yes | yes | template | +| Value Set Bindings | inline | limited | enum | template | +| Primitive Extensions | yes | no | no | no | +| Profiles | yes | no | no | no | +| Profile Validation | yes | no | no | no | +## Guides + +- **[Writer Generator Guide](docs/guides/writer-generator.md)** - Build custom code generators with the Writer base class +- **[Mustache Generator Guide](docs/guides/mustache-generator.md)** - Template-based code generation for any language +- **[TypeSchemaIndex Guide](docs/guides/typeschema-index.md)** - Type Schema structure and utilities +- **[Testing Generators Guide](docs/guides/testing-generators.md)** - Unit tests, snapshot testing, and best practices +- **[Contributing Guide](CONTRIBUTING.md)** - Development setup and workflow ## Versions & Release Cycle @@ -204,7 +205,7 @@ Use the new `localPackage` helper to point the builder at an on-disk FHIR packag .localTgzPackage("./packages/my-custom-ig.tgz") ``` -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. +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. ### Intermediate - Type Schema @@ -263,7 +264,7 @@ Beyond resource-level filtering, tree shaking supports fine-grained field select 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. -##### Logical Promotion +#### Logical Model Promotion 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. @@ -291,9 +292,7 @@ For languages with built-in support (TypeScript, Python, C#), extend the `Writer - **FileSystemWriter**: Base class providing file I/O, directory management, and buffer handling (both disk and in-memory modes) - **Writer**: Extends FileSystemWriter with code formatting utilities (indentation, blocks, comments, line management) -- **Language Writers** (`TypeScript`, `Python`[^py], `CSharp`): Implement language-specific generation logic by traversing TypeSchema index and generating corresponding types, interfaces, or classes - -[^py]: For details on [Type Schema: Python SDK for FHIR](https://www.health-samurai.io/articles/type-schema-python-sdk-for-fhir) +- **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)) 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. @@ -373,8 +372,8 @@ See [examples/typescript-r4/](examples/typescript-r4/) for R4 profile tests and ## Support -- 🐛 [Issue Tracker](https://github.com/atomic-ehr/codegen/issues) +- [Issue Tracker](https://github.com/atomic-ehr/codegen/issues) --- -Built with ❤️ by the Atomic Healthcare team +Built by the Atomic Healthcare team diff --git a/assets/api/writer-generator/typescript/profile-helpers.ts b/assets/api/writer-generator/typescript/profile-helpers.ts new file mode 100644 index 00000000..4f45db45 --- /dev/null +++ b/assets/api/writer-generator/typescript/profile-helpers.ts @@ -0,0 +1,376 @@ +/** + * Runtime helpers for generated FHIR profile classes. + * + * This file is copied verbatim into every generated TypeScript output and + * imported by profile modules. It provides: + * + * - **Slice helpers** – match, get, set, and default-fill array slices + * defined by a FHIR StructureDefinition. + * - **Extension helpers** – read complex (nested) FHIR extensions into + * plain objects. + * - **Choice-type helpers** – wrap/unwrap polymorphic `value[x]` fields so + * profile classes can expose a flat API. + * - **Validation helpers** – lightweight structural checks that profile + * classes call from their `validate()` method. + * - **Misc utilities** – deep-match, deep-merge, path navigation. + */ + +// --------------------------------------------------------------------------- +// General utilities +// --------------------------------------------------------------------------- + +/** Type guard: `value` is a non-null, non-array plain object. */ +export const isRecord = (value: unknown): value is Record => { + return value !== null && typeof value === "object" && !Array.isArray(value); +}; + +/** + * Walk `path` segments from `root`, creating intermediate objects (or using + * the first element of an existing array) as needed. Returns the leaf object. + * + * Used by extension setters to reach a nested target inside a resource. + * + * @example + * ensurePath(resource, ["contact", "telecom"]) + * // → resource.contact.telecom (created if absent) + */ +export const ensurePath = (root: Record, path: string[]): Record => { + let current: Record = root; + for (const segment of path) { + if (Array.isArray(current[segment])) { + const list = current[segment] as unknown[]; + if (list.length === 0) { + list.push({}); + } + current = list[0] as Record; + } else { + if (!isRecord(current[segment])) { + current[segment] = {}; + } + current = current[segment] as Record; + } + } + return current; +}; + +// --------------------------------------------------------------------------- +// Deep match / merge +// --------------------------------------------------------------------------- + +/** + * Deep-merge `match` into `target`, mutating `target` in place. + * Skips prototype-pollution keys. Used internally by {@link applySliceMatch}. + */ +export const mergeMatch = (target: Record, match: Record): void => { + for (const [key, matchValue] of Object.entries(match)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + if (isRecord(matchValue)) { + if (isRecord(target[key])) { + mergeMatch(target[key] as Record, matchValue); + } else { + target[key] = { ...matchValue }; + } + } else { + target[key] = matchValue; + } + } +}; + +/** + * Shallow-clone `input` then deep-merge the slice discriminator values from + * `match` on top, returning a complete slice element ready for insertion. + * + * @example + * applySliceMatch({ text: "hi" }, { coding: { code: "vital-signs", system: "…" } }) + * // → { text: "hi", coding: { code: "vital-signs", system: "…" } } + */ +export const applySliceMatch = (input: Partial, match: Partial>): T => { + const result = { ...input } as Record; + mergeMatch(result, match); + return result as T; +}; + +/** + * Recursively test whether `value` structurally contains everything in + * `match`. Arrays are matched with "every match item has a corresponding + * value item" semantics; objects are matched key-by-key; primitives use `===`. + * + * This is the core discriminator check used to identify which array element + * belongs to a given FHIR slice. + */ +export const matchesValue = (value: unknown, match: unknown): boolean => { + if (Array.isArray(match)) { + if (!Array.isArray(value)) { + return false; + } + return match.every((matchItem) => value.some((item) => matchesValue(item, matchItem))); + } + if (isRecord(match)) { + if (!isRecord(value)) { + return false; + } + for (const [key, matchValue] of Object.entries(match)) { + if (!matchesValue((value as Record)[key], matchValue)) { + return false; + } + } + return true; + } + return value === match; +}; + +// --------------------------------------------------------------------------- +// Extension helpers +// --------------------------------------------------------------------------- + +/** + * Read a complex (nested) FHIR extension into a plain key/value object. + * + * Each entry in `config` describes one sub-extension by URL, the name of its + * value field (e.g. `"valueString"`), and whether it may repeat. + * + * @returns A record keyed by sub-extension URL, or `undefined` if the + * extension has no nested children. + */ +export const extractComplexExtension = ( + extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, + config: Array<{ name: string; valueField: string; isArray: boolean }>, +): Record | undefined => { + if (!extension?.extension) return undefined; + const result: Record = {}; + for (const { name, valueField, isArray } of config) { + const subExts = extension.extension.filter((e) => e.url === name); + if (isArray) { + result[name] = subExts.map((e) => (e as Record)[valueField]); + } else if (subExts[0]) { + result[name] = (subExts[0] as Record)[valueField]; + } + } + return result; +}; + +// --------------------------------------------------------------------------- +// Slice helpers +// --------------------------------------------------------------------------- + +/** + * Remove discriminator keys from a slice element, returning only the + * user-supplied portion. Used by slice getters so callers see a clean object + * without the fixed discriminator values baked in. + */ +export const stripMatchKeys = (slice: object, matchKeys: string[]): T => { + const result = { ...slice } as Record; + for (const key of matchKeys) { + delete result[key]; + } + return result as T; +}; + +/** + * Wrap a flat input object under a choice-type key before inserting into a + * slice. For example, a Quantity value destined for `valueQuantity` is + * wrapped as `{ valueQuantity: { ...input } }`. + * + * No-op when `input` is empty (the slice will contain only discriminator + * defaults). + */ +export const wrapSliceChoice = (input: object, choiceVariant: string): Partial => { + if (Object.keys(input).length === 0) return input as Partial; + return { [choiceVariant]: input } as Partial; +}; + +/** + * Inverse of {@link wrapSliceChoice}: strip discriminator keys, then hoist + * the value inside `choiceVariant` up to the top level. + * + * @example + * unwrapSliceChoice(raw, ["code"], "valueQuantity") + * // removes "code", moves raw.valueQuantity.* to top level + */ +export const unwrapSliceChoice = (slice: object, matchKeys: string[], choiceVariant: string): T => { + const result = { ...slice } as Record; + for (const key of matchKeys) { + delete result[key]; + } + const variantValue = result[choiceVariant]; + delete result[choiceVariant]; + if (isRecord(variantValue)) { + Object.assign(result, variantValue); + } + return result as T; +}; + +/** + * Ensure that every required slice has at least a stub element in the array. + * Each `match` is a discriminator pattern; if no existing item satisfies it, + * a deep clone of the pattern is appended. + * + * Called in `createResource` so that required slices are always present even + * when the caller omits them. + */ +export const ensureSliceDefaults = (items: T[], ...matches: Record[]): T[] => { + for (const match of matches) { + if (!items.some((item) => matchesValue(item, match))) { + items.push(structuredClone(match) as T); + } + } + return items; +}; + +/** + * Add `canonicalUrl` to `resource.meta.profile` if not already present. + * Creates `meta` and `profile` when missing. + */ +export const ensureProfile = (resource: { meta?: { profile?: string[] } }, canonicalUrl: string): void => { + const meta = (resource.meta ??= {}); + const profiles = (meta.profile ??= []); + if (!profiles.includes(canonicalUrl)) profiles.push(canonicalUrl); +}; + +/** + * Find or insert a slice element in `list`. If an element matching `match` + * already exists it is replaced in place; otherwise `value` is appended. + */ +export const setArraySlice = (list: T[], match: Record, value: T): void => { + const index = list.findIndex((item) => matchesValue(item, match)); + if (index === -1) { + list.push(value); + } else { + list[index] = value; + } +}; + +/** Return the first element in `list` that satisfies the slice discriminator `match`. */ +export const getArraySlice = (list: readonly T[] | undefined, match: Record): T | undefined => { + if (!list) return undefined; + return list.find((item) => matchesValue(item, match)); +}; + +// --------------------------------------------------------------------------- +// Validation helpers +// +// Each function returns an array of human-readable error strings (empty = ok). +// Profile classes spread them all into a single array from `validate()`. +// --------------------------------------------------------------------------- + +/** Checks that `field` is present (not `undefined` or `null`). */ +export const validateRequired = (res: Record, profileName: string, field: string): string[] => { + return res[field] === undefined || res[field] === null + ? [`${profileName}: required field '${field}' is missing`] + : []; +}; + +/** Checks that `field` is absent (profiles may exclude base fields). */ +export const validateExcluded = (res: Record, profileName: string, field: string): string[] => { + return res[field] !== undefined ? [`${profileName}: field '${field}' must not be present`] : []; +}; + +/** Checks that `field` structurally contains the expected fixed value. */ +export const validateFixedValue = ( + res: Record, + profileName: string, + field: string, + expected: unknown, +): string[] => { + return matchesValue(res[field], expected) + ? [] + : [`${profileName}: field '${field}' does not match expected fixed value`]; +}; + +/** + * Checks that the number of array elements matching `match` (a slice + * discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded. + */ +export const validateSliceCardinality = ( + res: Record, + profileName: string, + field: string, + match: Record, + sliceName: string, + min: number, + max: number, +): string[] => { + const items = res[field] as unknown[] | undefined; + const count = (items ?? []).filter((item) => matchesValue(item, match)).length; + const errors: string[] = []; + if (count < min) { + errors.push(`${profileName}.${field}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); + } + if (max > 0 && count > max) { + errors.push(`${profileName}.${field}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); + } + return errors; +}; + +/** + * Checks that at least one of the listed choice-type variants is present. + * E.g. `["effectiveDateTime", "effectivePeriod"]`. + */ +export const validateChoiceRequired = ( + res: Record, + profileName: string, + choices: string[], +): string[] => { + return choices.some((c) => res[c] !== undefined) + ? [] + : [`${profileName}: at least one of ${choices.join(", ")} is required`]; +}; + +/** + * Checks that the value of `field` has a code within `allowed`. + * Handles plain strings, Coding objects, and CodeableConcept objects. + * Skips validation when the field is absent. + */ +export const validateEnum = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; + if (typeof value === "string") { + return allowed.includes(value) + ? [] + : [`${profileName}: field '${field}' value '${value}' is not in allowed values`]; + } + const rec = value as Record; + // Coding + if (typeof rec.code === "string" && rec.system !== undefined) { + return allowed.includes(rec.code) + ? [] + : [`${profileName}: field '${field}' code '${rec.code}' is not in allowed values`]; + } + // CodeableConcept + if (Array.isArray(rec.coding)) { + const codes = (rec.coding as Record[]).map((c) => c.code as string).filter(Boolean); + const hasValid = codes.some((c) => allowed.includes(c)); + return hasValid ? [] : [`${profileName}: field '${field}' has no coding with an allowed code`]; + } + return []; +}; + +/** + * Checks that a Reference field points to one of the `allowed` resource + * types. Extracts the type from the `reference` string (the part before + * the first `/`). Skips validation when the field or reference is absent. + */ +export const validateReference = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; + const ref = (value as Record).reference as string | undefined; + if (!ref) return []; + const slashIdx = ref.indexOf("/"); + if (slashIdx === -1) return []; + const refType = ref.slice(0, slashIdx); + return allowed.includes(refType) + ? [] + : [`${profileName}: field '${field}' references '${refType}' but only ${allowed.join(", ")} are allowed`]; +}; diff --git a/biome.json b/biome.json index 8fd66011..99e2e879 100644 --- a/biome.json +++ b/biome.json @@ -17,6 +17,7 @@ "package.json", "examples/*/*.ts", "examples/*.ts", + "assets/api/writer-generator/**/*.ts", ".zed/*.json" ] }, diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts index 9e5a63eb..82c139fe 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthPlace.ts @@ -5,7 +5,7 @@ import type { Address } from "../../hl7-fhir-r4-core/Address"; import type { Extension } from "../../hl7-fhir-r4-core/Extension"; -import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type birthPlaceProfileParams = { valueAddress: Address; @@ -26,7 +26,7 @@ export class birthPlaceProfile { } static createResource (args: birthPlaceProfileParams) : Extension { - const resource: Extension = { + const resource = { url: "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", valueAddress: args.valueAddress, } as unknown as Extension @@ -41,6 +41,8 @@ export class birthPlaceProfile { return this.resource } + // Field accessors + getValueAddress () : Address | undefined { return this.resource.valueAddress as Address | undefined } @@ -59,15 +61,16 @@ export class birthPlaceProfile { return this } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "url", "birthPlace"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthPlace", "birthPlace"); if (e) errors.push(e) } - if (!(r["valueAddress"] !== undefined)) { - errors.push("value: at least one of valueAddress is required") - } - return errors + // Validation + + validate(): string[] { + const profileName = "birthPlace" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "url"), + ...validateFixedValue(res, profileName, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthPlace"), + ...validateChoiceRequired(res, profileName, ["valueAddress"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts index bff1c3ab..53e7203b 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_birthTime.ts @@ -4,7 +4,7 @@ import type { Extension } from "../../hl7-fhir-r4-core/Extension"; -import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type birthTimeProfileParams = { valueDateTime: string; @@ -25,7 +25,7 @@ export class birthTimeProfile { } static createResource (args: birthTimeProfileParams) : Extension { - const resource: Extension = { + const resource = { url: "http://hl7.org/fhir/StructureDefinition/patient-birthTime", valueDateTime: args.valueDateTime, } as unknown as Extension @@ -40,6 +40,8 @@ export class birthTimeProfile { return this.resource } + // Field accessors + getValueDateTime () : string | undefined { return this.resource.valueDateTime as string | undefined } @@ -58,15 +60,16 @@ export class birthTimeProfile { return this } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "url", "birthTime"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthTime", "birthTime"); if (e) errors.push(e) } - if (!(r["valueDateTime"] !== undefined)) { - errors.push("value: at least one of valueDateTime is required") - } - return errors + // Validation + + validate(): string[] { + const profileName = "birthTime" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "url"), + ...validateFixedValue(res, profileName, "url", "http://hl7.org/fhir/StructureDefinition/patient-birthTime"), + ...validateChoiceRequired(res, profileName, ["valueDateTime"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts index 798d5870..07c3ec14 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_nationality.ts @@ -6,7 +6,7 @@ import type { CodeableConcept } from "../../hl7-fhir-r4-core/CodeableConcept"; import type { Extension } from "../../hl7-fhir-r4-core/Extension"; import type { Period } from "../../hl7-fhir-r4-core/Period"; -import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; // CanonicalURL: http://hl7.org/fhir/StructureDefinition/patient-nationality (pkg: hl7.fhir.r4.core#4.0.1) export class nationalityProfile { @@ -23,7 +23,7 @@ export class nationalityProfile { } static createResource () : Extension { - const resource: Extension = { + const resource = { url: "http://hl7.org/fhir/StructureDefinition/patient-nationality", } as unknown as Extension return resource @@ -37,6 +37,8 @@ export class nationalityProfile { return this.resource } + // Field accessors + getUrl () : string | undefined { return this.resource.url as string | undefined } @@ -46,6 +48,8 @@ export class nationalityProfile { return this } + // Slices and extensions + public setCode (value: CodeableConcept): this { const list = (this.resource.extension ??= []) list.push({ url: "code", valueCodeableConcept: value } as Extension) @@ -78,12 +82,15 @@ export class nationalityProfile { return ext } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "url", "nationality"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "url", "http://hl7.org/fhir/StructureDefinition/patient-nationality", "nationality"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "nationality" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "url"), + ...validateFixedValue(res, profileName, "url", "http://hl7.org/fhir/StructureDefinition/patient-nationality"), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts index 9a505e7b..b3da9946 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Extension_own_prefix.ts @@ -4,7 +4,7 @@ import type { Extension } from "../../hl7-fhir-r4-core/Extension"; -import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type own_prefixProfileParams = { valueString: string; @@ -25,7 +25,7 @@ export class own_prefixProfile { } static createResource (args: own_prefixProfileParams) : Extension { - const resource: Extension = { + const resource = { url: "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", valueString: args.valueString, } as unknown as Extension @@ -40,6 +40,8 @@ export class own_prefixProfile { return this.resource } + // Field accessors + getValueString () : string | undefined { return this.resource.valueString as string | undefined } @@ -58,15 +60,16 @@ export class own_prefixProfile { return this } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "url", "own-prefix"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "url", "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix", "own-prefix"); if (e) errors.push(e) } - if (!(r["valueString"] !== undefined)) { - errors.push("value: at least one of valueString is required") - } - return errors + // Validation + + validate(): string[] { + const profileName = "own-prefix" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "url"), + ...validateFixedValue(res, profileName, "url", "http://hl7.org/fhir/StructureDefinition/humanname-own-prefix"), + ...validateChoiceRequired(res, profileName, ["valueString"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts index 2449e9d8..58b8c541 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bodyweight.ts @@ -15,7 +15,7 @@ export interface observation_bodyweight extends Observation { export type Observation_bodyweight_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_bodyweightProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -27,14 +27,13 @@ export type observation_bodyweightProfileParams = { export class observation_bodyweightProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/bodyweight" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/bodyweight")) profiles.push("http://hl7.org/fhir/StructureDefinition/bodyweight") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/bodyweight") } static from (resource: Observation) : observation_bodyweightProfile { @@ -42,16 +41,18 @@ export class observation_bodyweightProfile { } static createResource (args: observation_bodyweightProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_bodyweightProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, category: categoryWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bodyweight"] }, + meta: { profile: [observation_bodyweightProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -64,6 +65,8 @@ export class observation_bodyweightProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -131,53 +134,46 @@ export class observation_bodyweightProfile { return this.resource as observation_bodyweight } + // Slices and extensions + public setVSCat (input?: Observation_bodyweight_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bodyweightProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): Observation_bodyweight_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bodyweightProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_bodyweight_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bodyweightProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-bodyweight"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-bodyweight.category")) - { const e = validateRequired(r, "code", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-bodyweight"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-bodyweight"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "observation-bodyweight" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts index db4fa14f..d670fad7 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_bp.ts @@ -18,7 +18,7 @@ export type Observation_bp_Category_VSCatSliceInput = Omit & Quantity; export type Observation_bp_Component_DiastolicBPSliceInput = Omit & Quantity; -import { applySliceMatch, matchesSlice, extractSliceSimplified, wrapSliceChoice, flattenSliceChoice, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, wrapSliceChoice, unwrapSliceChoice, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_bpProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -31,14 +31,15 @@ export type observation_bpProfileParams = { export class observation_bpProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/bp" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private static readonly SystolicBPSliceMatch: Record = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} + private static readonly DiastolicBPSliceMatch: Record = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/bp")) profiles.push("http://hl7.org/fhir/StructureDefinition/bp") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/bp") } static from (resource: Observation) : observation_bpProfile { @@ -46,21 +47,24 @@ export class observation_bpProfile { } static createResource (args: observation_bpProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const componentDefaults = [{"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}},{"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}] as unknown[] - const componentWithDefaults = [...(args.component ?? [])] as unknown[] - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record))) componentWithDefaults.push(componentDefaults[0]!) - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record))) componentWithDefaults.push(componentDefaults[1]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_bpProfile.VSCatSliceMatch, + ) + const componentWithDefaults = ensureSliceDefaults( + [...(args.component ?? [])], + observation_bpProfile.SystolicBPSliceMatch, + observation_bpProfile.DiastolicBPSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, category: categoryWithDefaults, component: componentWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bp"] }, + meta: { profile: [observation_bpProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -73,6 +77,8 @@ export class observation_bpProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -149,115 +155,90 @@ export class observation_bpProfile { return this.resource as observation_bp } + // Slices and extensions + public setVSCat (input?: Observation_bp_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public setSystolicBP (input?: Observation_bp_Component_SystolicBPSliceInput): this { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const value = applySliceMatch(wrapSliceChoice((input ?? {}) as Record, "valueQuantity"), match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.SystolicBPSliceMatch + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public setDiastolicBP (input?: Observation_bp_Component_DiastolicBPSliceInput): this { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const value = applySliceMatch(wrapSliceChoice((input ?? {}) as Record, "valueQuantity"), match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.DiastolicBPSliceMatch + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public getVSCat (): Observation_bp_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_bp_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } public getSystolicBP (): Observation_bp_Component_SystolicBPSliceInput | undefined { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.SystolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as Observation_bp_Component_SystolicBPSliceInput + return unwrapSliceChoice(item, ["code"], "valueQuantity") } public getSystolicBPRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.SystolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) return item } public getDiastolicBP (): Observation_bp_Component_DiastolicBPSliceInput | undefined { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.DiastolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as Observation_bp_Component_DiastolicBPSliceInput + return unwrapSliceChoice(item, ["code"], "valueQuantity") } public getDiastolicBPRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.DiastolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-bp"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-bp"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-bp"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-bp.category")) - { const e = validateRequired(r, "code", "observation-bp"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, "observation-bp"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-bp"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-bp"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-bp"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-bp"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}}, "SystolicBP", 1, 1, "observation-bp.component")) - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}, "DiastolicBP", 1, 1, "observation-bp.component")) - return errors + // Validation + + validate(): string[] { + const profileName = "observation-bp" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}}, "SystolicBP", 1, 1), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}, "DiastolicBP", 1, 1), + ] } } diff --git a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts index ad0e95fa..8f87ef5a 100644 --- a/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts +++ b/examples/typescript-r4/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts @@ -14,7 +14,7 @@ export interface observation_vitalsigns extends Observation { export type Observation_vitalsigns_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_vitalsignsProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -27,14 +27,13 @@ export type observation_vitalsignsProfileParams = { export class observation_vitalsignsProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/vitalsigns" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/vitalsigns")) profiles.push("http://hl7.org/fhir/StructureDefinition/vitalsigns") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/vitalsigns") } static from (resource: Observation) : observation_vitalsignsProfile { @@ -42,16 +41,18 @@ export class observation_vitalsignsProfile { } static createResource (args: observation_vitalsignsProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_vitalsignsProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", category: categoryWithDefaults, status: args.status, code: args.code, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/vitalsigns"] }, + meta: { profile: [observation_vitalsignsProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -64,6 +65,8 @@ export class observation_vitalsignsProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -122,52 +125,45 @@ export class observation_vitalsignsProfile { return this.resource as observation_vitalsigns } + // Slices and extensions + public setVSCat (input?: Observation_vitalsigns_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_vitalsignsProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): Observation_vitalsigns_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_vitalsignsProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_vitalsigns_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_vitalsignsProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-vitalsigns"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-vitalsigns.category")) - { const e = validateRequired(r, "code", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-vitalsigns"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-vitalsigns"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "observation-vitalsigns" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ] } } diff --git a/examples/typescript-r4/fhir-types/profile-helpers.ts b/examples/typescript-r4/fhir-types/profile-helpers.ts index ab04e6bd..4f45db45 100644 --- a/examples/typescript-r4/fhir-types/profile-helpers.ts +++ b/examples/typescript-r4/fhir-types/profile-helpers.ts @@ -1,12 +1,40 @@ -// WARNING: This file is autogenerated by @atomic-ehr/codegen. -// GitHub: https://github.com/atomic-ehr/codegen -// Any manual changes made to this file may be overwritten. +/** + * Runtime helpers for generated FHIR profile classes. + * + * This file is copied verbatim into every generated TypeScript output and + * imported by profile modules. It provides: + * + * - **Slice helpers** – match, get, set, and default-fill array slices + * defined by a FHIR StructureDefinition. + * - **Extension helpers** – read complex (nested) FHIR extensions into + * plain objects. + * - **Choice-type helpers** – wrap/unwrap polymorphic `value[x]` fields so + * profile classes can expose a flat API. + * - **Validation helpers** – lightweight structural checks that profile + * classes call from their `validate()` method. + * - **Misc utilities** – deep-match, deep-merge, path navigation. + */ +// --------------------------------------------------------------------------- +// General utilities +// --------------------------------------------------------------------------- + +/** Type guard: `value` is a non-null, non-array plain object. */ export const isRecord = (value: unknown): value is Record => { return value !== null && typeof value === "object" && !Array.isArray(value); -} +}; -export const getOrCreateObjectAtPath = (root: Record, path: string[]): Record => { +/** + * Walk `path` segments from `root`, creating intermediate objects (or using + * the first element of an existing array) as needed. Returns the leaf object. + * + * Used by extension setters to reach a nested target inside a resource. + * + * @example + * ensurePath(resource, ["contact", "telecom"]) + * // → resource.contact.telecom (created if absent) + */ +export const ensurePath = (root: Record, path: string[]): Record => { let current: Record = root; for (const segment of path) { if (Array.isArray(current[segment])) { @@ -15,8 +43,7 @@ export const getOrCreateObjectAtPath = (root: Record, path: str list.push({}); } current = list[0] as Record; - } - else { + } else { if (!isRecord(current[segment])) { current[segment] = {}; } @@ -24,8 +51,16 @@ export const getOrCreateObjectAtPath = (root: Record, path: str } } return current; -} +}; + +// --------------------------------------------------------------------------- +// Deep match / merge +// --------------------------------------------------------------------------- +/** + * Deep-merge `match` into `target`, mutating `target` in place. + * Skips prototype-pollution keys. Used internally by {@link applySliceMatch}. + */ export const mergeMatch = (target: Record, match: Record): void => { for (const [key, matchValue] of Object.entries(match)) { if (key === "__proto__" || key === "constructor" || key === "prototype") { @@ -34,23 +69,37 @@ export const mergeMatch = (target: Record, match: Record, matchValue); - } - else { + } else { target[key] = { ...matchValue }; } - } - else { + } else { target[key] = matchValue; } } -} +}; -export const applySliceMatch = >(input: T, match: Record): T => { +/** + * Shallow-clone `input` then deep-merge the slice discriminator values from + * `match` on top, returning a complete slice element ready for insertion. + * + * @example + * applySliceMatch({ text: "hi" }, { coding: { code: "vital-signs", system: "…" } }) + * // → { text: "hi", coding: { code: "vital-signs", system: "…" } } + */ +export const applySliceMatch = (input: Partial, match: Partial>): T => { const result = { ...input } as Record; mergeMatch(result, match); return result as T; -} +}; +/** + * Recursively test whether `value` structurally contains everything in + * `match`. Arrays are matched with "every match item has a corresponding + * value item" semantics; objects are matched key-by-key; primitives use `===`. + * + * This is the core discriminator check used to identify which array element + * belongs to a given FHIR slice. + */ export const matchesValue = (value: unknown, match: unknown): boolean => { if (Array.isArray(match)) { if (!Array.isArray(value)) { @@ -70,41 +119,77 @@ export const matchesValue = (value: unknown, match: unknown): boolean => { return true; } return value === match; -} +}; -export const matchesSlice = (value: unknown, match: Record): boolean => { - return matchesValue(value, match); -} +// --------------------------------------------------------------------------- +// Extension helpers +// --------------------------------------------------------------------------- -export const extractComplexExtension = (extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, config: Array<{ name: string; valueField: string; isArray: boolean }>): Record | undefined => { +/** + * Read a complex (nested) FHIR extension into a plain key/value object. + * + * Each entry in `config` describes one sub-extension by URL, the name of its + * value field (e.g. `"valueString"`), and whether it may repeat. + * + * @returns A record keyed by sub-extension URL, or `undefined` if the + * extension has no nested children. + */ +export const extractComplexExtension = ( + extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, + config: Array<{ name: string; valueField: string; isArray: boolean }>, +): Record | undefined => { if (!extension?.extension) return undefined; const result: Record = {}; for (const { name, valueField, isArray } of config) { - const subExts = extension.extension.filter(e => e.url === name); + const subExts = extension.extension.filter((e) => e.url === name); if (isArray) { - result[name] = subExts.map(e => (e as Record)[valueField]); - } - else if (subExts[0]) { + result[name] = subExts.map((e) => (e as Record)[valueField]); + } else if (subExts[0]) { result[name] = (subExts[0] as Record)[valueField]; } } return result; -} +}; + +// --------------------------------------------------------------------------- +// Slice helpers +// --------------------------------------------------------------------------- -export const extractSliceSimplified = >(slice: T, matchKeys: string[]): Partial => { +/** + * Remove discriminator keys from a slice element, returning only the + * user-supplied portion. Used by slice getters so callers see a clean object + * without the fixed discriminator values baked in. + */ +export const stripMatchKeys = (slice: object, matchKeys: string[]): T => { const result = { ...slice } as Record; for (const key of matchKeys) { delete result[key]; } - return result as Partial; -} + return result as T; +}; -export const wrapSliceChoice = (input: Record, choiceVariant: string): Record => { - if (Object.keys(input).length === 0) return input; - return { [choiceVariant]: input }; -} +/** + * Wrap a flat input object under a choice-type key before inserting into a + * slice. For example, a Quantity value destined for `valueQuantity` is + * wrapped as `{ valueQuantity: { ...input } }`. + * + * No-op when `input` is empty (the slice will contain only discriminator + * defaults). + */ +export const wrapSliceChoice = (input: object, choiceVariant: string): Partial => { + if (Object.keys(input).length === 0) return input as Partial; + return { [choiceVariant]: input } as Partial; +}; -export const flattenSliceChoice = (slice: Record, matchKeys: string[], choiceVariant: string): Record => { +/** + * Inverse of {@link wrapSliceChoice}: strip discriminator keys, then hoist + * the value inside `choiceVariant` up to the top level. + * + * @example + * unwrapSliceChoice(raw, ["code"], "valueQuantity") + * // removes "code", moves raw.valueQuantity.* to top level + */ +export const unwrapSliceChoice = (slice: object, matchKeys: string[], choiceVariant: string): T => { const result = { ...slice } as Record; for (const key of matchKeys) { delete result[key]; @@ -114,58 +199,178 @@ export const flattenSliceChoice = (slice: Record, matchKeys: st if (isRecord(variantValue)) { Object.assign(result, variantValue); } - return result; -} + return result as T; +}; -export const validateRequired = (r: Record, field: string, path: string): string | undefined => { - return r[field] === undefined || r[field] === null ? `${path}: required field '${field}' is missing` : undefined; -} +/** + * Ensure that every required slice has at least a stub element in the array. + * Each `match` is a discriminator pattern; if no existing item satisfies it, + * a deep clone of the pattern is appended. + * + * Called in `createResource` so that required slices are always present even + * when the caller omits them. + */ +export const ensureSliceDefaults = (items: T[], ...matches: Record[]): T[] => { + for (const match of matches) { + if (!items.some((item) => matchesValue(item, match))) { + items.push(structuredClone(match) as T); + } + } + return items; +}; + +/** + * Add `canonicalUrl` to `resource.meta.profile` if not already present. + * Creates `meta` and `profile` when missing. + */ +export const ensureProfile = (resource: { meta?: { profile?: string[] } }, canonicalUrl: string): void => { + const meta = (resource.meta ??= {}); + const profiles = (meta.profile ??= []); + if (!profiles.includes(canonicalUrl)) profiles.push(canonicalUrl); +}; + +/** + * Find or insert a slice element in `list`. If an element matching `match` + * already exists it is replaced in place; otherwise `value` is appended. + */ +export const setArraySlice = (list: T[], match: Record, value: T): void => { + const index = list.findIndex((item) => matchesValue(item, match)); + if (index === -1) { + list.push(value); + } else { + list[index] = value; + } +}; -export const validateExcluded = (r: Record, field: string, path: string): string | undefined => { - return r[field] !== undefined ? `${path}: field '${field}' must not be present` : undefined; -} +/** Return the first element in `list` that satisfies the slice discriminator `match`. */ +export const getArraySlice = (list: readonly T[] | undefined, match: Record): T | undefined => { + if (!list) return undefined; + return list.find((item) => matchesValue(item, match)); +}; -export const validateFixedValue = (r: Record, field: string, expected: unknown, path: string): string | undefined => { - return matchesValue(r[field], expected) ? undefined : `${path}: field '${field}' does not match expected fixed value`; -} +// --------------------------------------------------------------------------- +// Validation helpers +// +// Each function returns an array of human-readable error strings (empty = ok). +// Profile classes spread them all into a single array from `validate()`. +// --------------------------------------------------------------------------- -export const validateSliceCardinality = (items: unknown[] | undefined, match: Record, sliceName: string, min: number, max: number, path: string): string[] => { - const count = (items ?? []).filter(item => matchesSlice(item, match)).length; +/** Checks that `field` is present (not `undefined` or `null`). */ +export const validateRequired = (res: Record, profileName: string, field: string): string[] => { + return res[field] === undefined || res[field] === null + ? [`${profileName}: required field '${field}' is missing`] + : []; +}; + +/** Checks that `field` is absent (profiles may exclude base fields). */ +export const validateExcluded = (res: Record, profileName: string, field: string): string[] => { + return res[field] !== undefined ? [`${profileName}: field '${field}' must not be present`] : []; +}; + +/** Checks that `field` structurally contains the expected fixed value. */ +export const validateFixedValue = ( + res: Record, + profileName: string, + field: string, + expected: unknown, +): string[] => { + return matchesValue(res[field], expected) + ? [] + : [`${profileName}: field '${field}' does not match expected fixed value`]; +}; + +/** + * Checks that the number of array elements matching `match` (a slice + * discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded. + */ +export const validateSliceCardinality = ( + res: Record, + profileName: string, + field: string, + match: Record, + sliceName: string, + min: number, + max: number, +): string[] => { + const items = res[field] as unknown[] | undefined; + const count = (items ?? []).filter((item) => matchesValue(item, match)).length; const errors: string[] = []; if (count < min) { - errors.push(`${path}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); + errors.push(`${profileName}.${field}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); } if (max > 0 && count > max) { - errors.push(`${path}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); + errors.push(`${profileName}.${field}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); } return errors; -} +}; + +/** + * Checks that at least one of the listed choice-type variants is present. + * E.g. `["effectiveDateTime", "effectivePeriod"]`. + */ +export const validateChoiceRequired = ( + res: Record, + profileName: string, + choices: string[], +): string[] => { + return choices.some((c) => res[c] !== undefined) + ? [] + : [`${profileName}: at least one of ${choices.join(", ")} is required`]; +}; -export const validateEnum = (value: unknown, allowed: string[], field: string, path: string): string | undefined => { - if (value === undefined || value === null) return undefined; - if (typeof value === 'string') { - return allowed.includes(value) ? undefined : `${path}: field '${field}' value '${value}' is not in allowed values`; +/** + * Checks that the value of `field` has a code within `allowed`. + * Handles plain strings, Coding objects, and CodeableConcept objects. + * Skips validation when the field is absent. + */ +export const validateEnum = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; + if (typeof value === "string") { + return allowed.includes(value) + ? [] + : [`${profileName}: field '${field}' value '${value}' is not in allowed values`]; } const rec = value as Record; // Coding - if (typeof rec.code === 'string' && rec.system !== undefined) { - return allowed.includes(rec.code) ? undefined : `${path}: field '${field}' code '${rec.code}' is not in allowed values`; + if (typeof rec.code === "string" && rec.system !== undefined) { + return allowed.includes(rec.code) + ? [] + : [`${profileName}: field '${field}' code '${rec.code}' is not in allowed values`]; } // CodeableConcept if (Array.isArray(rec.coding)) { - const codes = (rec.coding as Array>).map(c => c.code as string).filter(Boolean); - const hasValid = codes.some(c => allowed.includes(c)); - return hasValid ? undefined : `${path}: field '${field}' has no coding with an allowed code`; + const codes = (rec.coding as Record[]).map((c) => c.code as string).filter(Boolean); + const hasValid = codes.some((c) => allowed.includes(c)); + return hasValid ? [] : [`${profileName}: field '${field}' has no coding with an allowed code`]; } - return undefined; -} + return []; +}; -export const validateReference = (value: unknown, allowed: string[], field: string, path: string): string | undefined => { - if (value === undefined || value === null) return undefined; +/** + * Checks that a Reference field points to one of the `allowed` resource + * types. Extracts the type from the `reference` string (the part before + * the first `/`). Skips validation when the field or reference is absent. + */ +export const validateReference = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; const ref = (value as Record).reference as string | undefined; - if (!ref) return undefined; - const slashIdx = ref.indexOf('/'); - if (slashIdx === -1) return undefined; + if (!ref) return []; + const slashIdx = ref.indexOf("/"); + if (slashIdx === -1) return []; const refType = ref.slice(0, slashIdx); - return allowed.includes(refType) ? undefined : `${path}: field '${field}' references '${refType}' but only ${allowed.join(', ')} are allowed`; -} + return allowed.includes(refType) + ? [] + : [`${profileName}: field '${field}' references '${refType}' but only ${allowed.join(", ")} are allowed`]; +}; diff --git a/examples/typescript-r4/profile-bp.test.ts b/examples/typescript-r4/profile-bp.test.ts index 78385e50..ad42f32c 100644 --- a/examples/typescript-r4/profile-bp.test.ts +++ b/examples/typescript-r4/profile-bp.test.ts @@ -31,7 +31,7 @@ describe("blood pressure profile", () => { test("freshly created profile is not yet valid (missing effective)", () => { const errors = profile.validate(); - expect(errors).toEqual(["effective: at least one of effectiveDateTime, effectivePeriod is required"]); + expect(errors).toEqual(["observation-bp: at least one of effectiveDateTime, effectivePeriod is required"]); }); test("create() auto-populates component with systolic/diastolic stubs", () => { diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts index ad0e95fa..8f87ef5a 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-r4-core/profiles/Observation_observation_vitalsigns.ts @@ -14,7 +14,7 @@ export interface observation_vitalsigns extends Observation { export type Observation_vitalsigns_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_vitalsignsProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -27,14 +27,13 @@ export type observation_vitalsignsProfileParams = { export class observation_vitalsignsProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/vitalsigns" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/vitalsigns")) profiles.push("http://hl7.org/fhir/StructureDefinition/vitalsigns") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/vitalsigns") } static from (resource: Observation) : observation_vitalsignsProfile { @@ -42,16 +41,18 @@ export class observation_vitalsignsProfile { } static createResource (args: observation_vitalsignsProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_vitalsignsProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", category: categoryWithDefaults, status: args.status, code: args.code, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/vitalsigns"] }, + meta: { profile: [observation_vitalsignsProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -64,6 +65,8 @@ export class observation_vitalsignsProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -122,52 +125,45 @@ export class observation_vitalsignsProfile { return this.resource as observation_vitalsigns } + // Slices and extensions + public setVSCat (input?: Observation_vitalsigns_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_vitalsignsProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): Observation_vitalsigns_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_vitalsignsProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_vitalsigns_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_vitalsignsProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-vitalsigns"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-vitalsigns.category")) - { const e = validateRequired(r, "code", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-vitalsigns"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-vitalsigns"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-vitalsigns"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "observation-vitalsigns" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ] } } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts index 80bba279..c9ab6c05 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBloodPressureProfile.ts @@ -21,11 +21,11 @@ export type USCoreBloodPressureProfile_Category_VSCatSliceInput = Omit & Quantity; export type USCoreBloodPressureProfile_Component_DiastolicSliceInput = Omit & Quantity; -import { applySliceMatch, matchesSlice, extractSliceSimplified, wrapSliceChoice, flattenSliceChoice, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, wrapSliceChoice, unwrapSliceChoice, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type USCoreBloodPressureProfileProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); - subject: Reference<"USCorePatientProfile">; + subject: Reference<"Patient">; category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; component?: ObservationComponent[]; } @@ -34,14 +34,15 @@ export type USCoreBloodPressureProfileProfileParams = { export class USCoreBloodPressureProfileProfile { static readonly canonicalUrl = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private static readonly systolicSliceMatch: Record = {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} + private static readonly diastolicSliceMatch: Record = {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure")) profiles.push("http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure") + ensureProfile(resource, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure") } static from (resource: Observation) : USCoreBloodPressureProfileProfile { @@ -49,21 +50,24 @@ export class USCoreBloodPressureProfileProfile { } static createResource (args: USCoreBloodPressureProfileProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const componentDefaults = [{"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}},{"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}}] as unknown[] - const componentWithDefaults = [...(args.component ?? [])] as unknown[] - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} as Record))) componentWithDefaults.push(componentDefaults[0]!) - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} as Record))) componentWithDefaults.push(componentDefaults[1]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + USCoreBloodPressureProfileProfile.VSCatSliceMatch, + ) + const componentWithDefaults = ensureSliceDefaults( + [...(args.component ?? [])], + USCoreBloodPressureProfileProfile.systolicSliceMatch, + USCoreBloodPressureProfileProfile.diastolicSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}, category: categoryWithDefaults, component: componentWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-blood-pressure"] }, + meta: { profile: [USCoreBloodPressureProfileProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -76,6 +80,8 @@ export class USCoreBloodPressureProfileProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -85,11 +91,11 @@ export class USCoreBloodPressureProfileProfile { return this } - getSubject () : Reference<"USCorePatientProfile"> | undefined { - return this.resource.subject as Reference<"USCorePatientProfile"> | undefined + getSubject () : Reference<"Patient"> | undefined { + return this.resource.subject as Reference<"Patient"> | undefined } - setSubject (value: Reference<"USCorePatientProfile">) : this { + setSubject (value: Reference<"Patient">) : this { Object.assign(this.resource, { subject: value }) return this } @@ -242,116 +248,91 @@ export class USCoreBloodPressureProfileProfile { return this.resource as USCoreBloodPressureProfile } + // Slices and extensions + public setVSCat (input?: USCoreBloodPressureProfile_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreBloodPressureProfileProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public setSystolic (input?: USCoreBloodPressureProfile_Component_SystolicSliceInput): this { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} as Record - const value = applySliceMatch(wrapSliceChoice((input ?? {}) as Record, "valueQuantity"), match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreBloodPressureProfileProfile.systolicSliceMatch + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public setDiastolic (input?: USCoreBloodPressureProfile_Component_DiastolicSliceInput): this { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} as Record - const value = applySliceMatch(wrapSliceChoice((input ?? {}) as Record, "valueQuantity"), match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreBloodPressureProfileProfile.diastolicSliceMatch + const wrapped = wrapSliceChoice(input ?? {}, "valueQuantity") + const value = applySliceMatch(wrapped, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public getVSCat (): USCoreBloodPressureProfile_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as USCoreBloodPressureProfile_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } public getSystolic (): USCoreBloodPressureProfile_Component_SystolicSliceInput | undefined { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.systolicSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as USCoreBloodPressureProfile_Component_SystolicSliceInput + return unwrapSliceChoice(item, ["code"], "valueQuantity") } public getSystolicRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.systolicSliceMatch + const item = getArraySlice(this.resource.component, match) return item } public getDiastolic (): USCoreBloodPressureProfile_Component_DiastolicSliceInput | undefined { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.diastolicSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return flattenSliceChoice(item as unknown as Record, ["code"], "valueQuantity") as USCoreBloodPressureProfile_Component_DiastolicSliceInput + return unwrapSliceChoice(item, ["code"], "valueQuantity") } public getDiastolicRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBloodPressureProfileProfile.diastolicSliceMatch + const item = getArraySlice(this.resource.component, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "USCoreBloodPressureProfile.category")) - { const e = validateRequired(r, "code", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}, "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["USCorePatientProfile"], "subject", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}}, "systolic", 1, 1, "USCoreBloodPressureProfile.component")) - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}}, "diastolic", 1, 1, "USCoreBloodPressureProfile.component")) - { const e = validateReference(r["performer"], ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","USCorePatientProfile","USCorePractitionerProfile","USCoreRelatedPersonProfile"], "performer", "USCoreBloodPressureProfile"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "USCoreBloodPressureProfile" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"system":"http://loinc.org","code":"85354-9"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]}}, "systolic", 1, 1), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":[{"system":"http://loinc.org","code":"8462-4"}]}}, "diastolic", 1, 1), + ...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]), + ] } } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts index b3d2d42a..55d33701 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreBodyWeightProfile.ts @@ -18,11 +18,11 @@ export interface USCoreBodyWeightProfile extends Observation { export type USCoreBodyWeightProfile_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type USCoreBodyWeightProfileProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); - subject: Reference<"USCorePatientProfile">; + subject: Reference<"Patient">; category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; } @@ -30,14 +30,13 @@ export type USCoreBodyWeightProfileProfileParams = { export class USCoreBodyWeightProfileProfile { static readonly canonicalUrl = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight")) profiles.push("http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight") + ensureProfile(resource, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight") } static from (resource: Observation) : USCoreBodyWeightProfileProfile { @@ -45,16 +44,18 @@ export class USCoreBodyWeightProfileProfile { } static createResource (args: USCoreBodyWeightProfileProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + USCoreBodyWeightProfileProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}, category: categoryWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-body-weight"] }, + meta: { profile: [USCoreBodyWeightProfileProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -67,6 +68,8 @@ export class USCoreBodyWeightProfileProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -76,11 +79,11 @@ export class USCoreBodyWeightProfileProfile { return this } - getSubject () : Reference<"USCorePatientProfile"> | undefined { - return this.resource.subject as Reference<"USCorePatientProfile"> | undefined + getSubject () : Reference<"Patient"> | undefined { + return this.resource.subject as Reference<"Patient"> | undefined } - setSubject (value: Reference<"USCorePatientProfile">) : this { + setSubject (value: Reference<"Patient">) : this { Object.assign(this.resource, { subject: value }) return this } @@ -224,54 +227,47 @@ export class USCoreBodyWeightProfileProfile { return this.resource as USCoreBodyWeightProfile } + // Slices and extensions + public setVSCat (input?: USCoreBodyWeightProfile_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreBodyWeightProfileProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): USCoreBodyWeightProfile_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBodyWeightProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as USCoreBodyWeightProfile_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreBodyWeightProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "USCoreBodyWeightProfile.category")) - { const e = validateRequired(r, "code", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}, "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["USCorePatientProfile"], "subject", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - { const e = validateReference(r["performer"], ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","USCorePatientProfile","USCorePractitionerProfile","USCoreRelatedPersonProfile"], "performer", "USCoreBodyWeightProfile"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "USCoreBodyWeightProfile" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"system":"http://loinc.org","code":"29463-7"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]), + ] } } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreVitalSignsProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreVitalSignsProfile.ts index 12d7b44a..af9c2dff 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreVitalSignsProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Observation_USCoreVitalSignsProfile.ts @@ -18,12 +18,12 @@ export interface USCoreVitalSignsProfile extends Observation { export type USCoreVitalSignsProfile_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type USCoreVitalSignsProfileProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); code: CodeableConcept<("2708-6" | "29463-7" | "3140-1" | "3150-0" | "3151-8" | "39156-5" | "59408-5" | "59575-1" | "59576-9" | "77606-2" | "8287-5" | "8289-1" | "8302-2" | "8306-3" | "8310-5" | "8462-4" | "8478-0" | "8480-6" | "8867-4" | "9279-1" | "9843-4" | string)>; - subject: Reference<"USCorePatientProfile">; + subject: Reference<"Patient">; category?: CodeableConcept<("social-history" | "vital-signs" | "imaging" | "laboratory" | "procedure" | "survey" | "exam" | "therapy" | "activity" | string)>[]; } @@ -31,14 +31,13 @@ export type USCoreVitalSignsProfileProfileParams = { export class USCoreVitalSignsProfileProfile { static readonly canonicalUrl = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs")) profiles.push("http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs") + ensureProfile(resource, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs") } static from (resource: Observation) : USCoreVitalSignsProfileProfile { @@ -46,16 +45,18 @@ export class USCoreVitalSignsProfileProfile { } static createResource (args: USCoreVitalSignsProfileProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + USCoreVitalSignsProfileProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", category: categoryWithDefaults, status: args.status, code: args.code, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs"] }, + meta: { profile: [USCoreVitalSignsProfileProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -68,6 +69,8 @@ export class USCoreVitalSignsProfileProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -86,11 +89,11 @@ export class USCoreVitalSignsProfileProfile { return this } - getSubject () : Reference<"USCorePatientProfile"> | undefined { - return this.resource.subject as Reference<"USCorePatientProfile"> | undefined + getSubject () : Reference<"Patient"> | undefined { + return this.resource.subject as Reference<"Patient"> | undefined } - setSubject (value: Reference<"USCorePatientProfile">) : this { + setSubject (value: Reference<"Patient">) : this { Object.assign(this.resource, { subject: value }) return this } @@ -225,53 +228,46 @@ export class USCoreVitalSignsProfileProfile { return this.resource as USCoreVitalSignsProfile } + // Slices and extensions + public setVSCat (input?: USCoreVitalSignsProfile_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = USCoreVitalSignsProfileProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): USCoreVitalSignsProfile_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreVitalSignsProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as USCoreVitalSignsProfile_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = USCoreVitalSignsProfileProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "USCoreVitalSignsProfile.category")) - { const e = validateRequired(r, "code", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["USCorePatientProfile"], "subject", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - { const e = validateReference(r["performer"], ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","USCorePatientProfile","USCorePractitionerProfile","USCoreRelatedPersonProfile"], "performer", "USCoreVitalSignsProfile"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "USCoreVitalSignsProfile" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "performer", ["PractitionerRole","USCoreCareTeam","USCoreOrganizationProfile","Patient","USCorePractitionerProfile","USCoreRelatedPersonProfile"]), + ] } } diff --git a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts index 133fa393..6a505f26 100644 --- a/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts +++ b/examples/typescript-us-core/fhir-types/hl7-fhir-us-core/profiles/Patient_USCorePatientProfile.ts @@ -31,7 +31,7 @@ export type USCorePatientProfile_TribalAffiliationInput = { isEnrolled?: boolean; } -import { extractComplexExtension, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, extractComplexExtension, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type USCorePatientProfileProfileParams = { identifier: Identifier[]; @@ -46,10 +46,7 @@ export class USCorePatientProfileProfile { constructor (resource: Patient) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient")) profiles.push("http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient") + ensureProfile(resource, "http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient") } static from (resource: Patient) : USCorePatientProfileProfile { @@ -57,11 +54,11 @@ export class USCorePatientProfileProfile { } static createResource (args: USCorePatientProfileProfileParams) : Patient { - const resource: Patient = { + const resource = { resourceType: "Patient", identifier: args.identifier, name: args.name, - meta: { profile: ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient"] }, + meta: { profile: [USCorePatientProfileProfile.canonicalUrl] }, } as unknown as Patient return resource } @@ -74,6 +71,8 @@ export class USCorePatientProfileProfile { return this.resource } + // Field accessors + getIdentifier () : Identifier[] | undefined { return this.resource.identifier as Identifier[] | undefined } @@ -96,6 +95,8 @@ export class USCorePatientProfileProfile { return this.resource as USCorePatientProfile } + // Slices and extensions + public setRace (input: USCorePatientProfile_RaceInput): this { const subExtensions: Extension[] = [] if (input.ombCategory !== undefined) { @@ -213,12 +214,15 @@ export class USCorePatientProfileProfile { return ext } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "identifier", "USCorePatientProfile"); if (e) errors.push(e) } - { const e = validateRequired(r, "name", "USCorePatientProfile"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "USCorePatientProfile" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "identifier"), + ...validateRequired(res, profileName, "name"), + ] } } diff --git a/examples/typescript-us-core/fhir-types/profile-helpers.ts b/examples/typescript-us-core/fhir-types/profile-helpers.ts index ab04e6bd..4f45db45 100644 --- a/examples/typescript-us-core/fhir-types/profile-helpers.ts +++ b/examples/typescript-us-core/fhir-types/profile-helpers.ts @@ -1,12 +1,40 @@ -// WARNING: This file is autogenerated by @atomic-ehr/codegen. -// GitHub: https://github.com/atomic-ehr/codegen -// Any manual changes made to this file may be overwritten. +/** + * Runtime helpers for generated FHIR profile classes. + * + * This file is copied verbatim into every generated TypeScript output and + * imported by profile modules. It provides: + * + * - **Slice helpers** – match, get, set, and default-fill array slices + * defined by a FHIR StructureDefinition. + * - **Extension helpers** – read complex (nested) FHIR extensions into + * plain objects. + * - **Choice-type helpers** – wrap/unwrap polymorphic `value[x]` fields so + * profile classes can expose a flat API. + * - **Validation helpers** – lightweight structural checks that profile + * classes call from their `validate()` method. + * - **Misc utilities** – deep-match, deep-merge, path navigation. + */ +// --------------------------------------------------------------------------- +// General utilities +// --------------------------------------------------------------------------- + +/** Type guard: `value` is a non-null, non-array plain object. */ export const isRecord = (value: unknown): value is Record => { return value !== null && typeof value === "object" && !Array.isArray(value); -} +}; -export const getOrCreateObjectAtPath = (root: Record, path: string[]): Record => { +/** + * Walk `path` segments from `root`, creating intermediate objects (or using + * the first element of an existing array) as needed. Returns the leaf object. + * + * Used by extension setters to reach a nested target inside a resource. + * + * @example + * ensurePath(resource, ["contact", "telecom"]) + * // → resource.contact.telecom (created if absent) + */ +export const ensurePath = (root: Record, path: string[]): Record => { let current: Record = root; for (const segment of path) { if (Array.isArray(current[segment])) { @@ -15,8 +43,7 @@ export const getOrCreateObjectAtPath = (root: Record, path: str list.push({}); } current = list[0] as Record; - } - else { + } else { if (!isRecord(current[segment])) { current[segment] = {}; } @@ -24,8 +51,16 @@ export const getOrCreateObjectAtPath = (root: Record, path: str } } return current; -} +}; + +// --------------------------------------------------------------------------- +// Deep match / merge +// --------------------------------------------------------------------------- +/** + * Deep-merge `match` into `target`, mutating `target` in place. + * Skips prototype-pollution keys. Used internally by {@link applySliceMatch}. + */ export const mergeMatch = (target: Record, match: Record): void => { for (const [key, matchValue] of Object.entries(match)) { if (key === "__proto__" || key === "constructor" || key === "prototype") { @@ -34,23 +69,37 @@ export const mergeMatch = (target: Record, match: Record, matchValue); - } - else { + } else { target[key] = { ...matchValue }; } - } - else { + } else { target[key] = matchValue; } } -} +}; -export const applySliceMatch = >(input: T, match: Record): T => { +/** + * Shallow-clone `input` then deep-merge the slice discriminator values from + * `match` on top, returning a complete slice element ready for insertion. + * + * @example + * applySliceMatch({ text: "hi" }, { coding: { code: "vital-signs", system: "…" } }) + * // → { text: "hi", coding: { code: "vital-signs", system: "…" } } + */ +export const applySliceMatch = (input: Partial, match: Partial>): T => { const result = { ...input } as Record; mergeMatch(result, match); return result as T; -} +}; +/** + * Recursively test whether `value` structurally contains everything in + * `match`. Arrays are matched with "every match item has a corresponding + * value item" semantics; objects are matched key-by-key; primitives use `===`. + * + * This is the core discriminator check used to identify which array element + * belongs to a given FHIR slice. + */ export const matchesValue = (value: unknown, match: unknown): boolean => { if (Array.isArray(match)) { if (!Array.isArray(value)) { @@ -70,41 +119,77 @@ export const matchesValue = (value: unknown, match: unknown): boolean => { return true; } return value === match; -} +}; -export const matchesSlice = (value: unknown, match: Record): boolean => { - return matchesValue(value, match); -} +// --------------------------------------------------------------------------- +// Extension helpers +// --------------------------------------------------------------------------- -export const extractComplexExtension = (extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, config: Array<{ name: string; valueField: string; isArray: boolean }>): Record | undefined => { +/** + * Read a complex (nested) FHIR extension into a plain key/value object. + * + * Each entry in `config` describes one sub-extension by URL, the name of its + * value field (e.g. `"valueString"`), and whether it may repeat. + * + * @returns A record keyed by sub-extension URL, or `undefined` if the + * extension has no nested children. + */ +export const extractComplexExtension = ( + extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, + config: Array<{ name: string; valueField: string; isArray: boolean }>, +): Record | undefined => { if (!extension?.extension) return undefined; const result: Record = {}; for (const { name, valueField, isArray } of config) { - const subExts = extension.extension.filter(e => e.url === name); + const subExts = extension.extension.filter((e) => e.url === name); if (isArray) { - result[name] = subExts.map(e => (e as Record)[valueField]); - } - else if (subExts[0]) { + result[name] = subExts.map((e) => (e as Record)[valueField]); + } else if (subExts[0]) { result[name] = (subExts[0] as Record)[valueField]; } } return result; -} +}; + +// --------------------------------------------------------------------------- +// Slice helpers +// --------------------------------------------------------------------------- -export const extractSliceSimplified = >(slice: T, matchKeys: string[]): Partial => { +/** + * Remove discriminator keys from a slice element, returning only the + * user-supplied portion. Used by slice getters so callers see a clean object + * without the fixed discriminator values baked in. + */ +export const stripMatchKeys = (slice: object, matchKeys: string[]): T => { const result = { ...slice } as Record; for (const key of matchKeys) { delete result[key]; } - return result as Partial; -} + return result as T; +}; -export const wrapSliceChoice = (input: Record, choiceVariant: string): Record => { - if (Object.keys(input).length === 0) return input; - return { [choiceVariant]: input }; -} +/** + * Wrap a flat input object under a choice-type key before inserting into a + * slice. For example, a Quantity value destined for `valueQuantity` is + * wrapped as `{ valueQuantity: { ...input } }`. + * + * No-op when `input` is empty (the slice will contain only discriminator + * defaults). + */ +export const wrapSliceChoice = (input: object, choiceVariant: string): Partial => { + if (Object.keys(input).length === 0) return input as Partial; + return { [choiceVariant]: input } as Partial; +}; -export const flattenSliceChoice = (slice: Record, matchKeys: string[], choiceVariant: string): Record => { +/** + * Inverse of {@link wrapSliceChoice}: strip discriminator keys, then hoist + * the value inside `choiceVariant` up to the top level. + * + * @example + * unwrapSliceChoice(raw, ["code"], "valueQuantity") + * // removes "code", moves raw.valueQuantity.* to top level + */ +export const unwrapSliceChoice = (slice: object, matchKeys: string[], choiceVariant: string): T => { const result = { ...slice } as Record; for (const key of matchKeys) { delete result[key]; @@ -114,58 +199,178 @@ export const flattenSliceChoice = (slice: Record, matchKeys: st if (isRecord(variantValue)) { Object.assign(result, variantValue); } - return result; -} + return result as T; +}; -export const validateRequired = (r: Record, field: string, path: string): string | undefined => { - return r[field] === undefined || r[field] === null ? `${path}: required field '${field}' is missing` : undefined; -} +/** + * Ensure that every required slice has at least a stub element in the array. + * Each `match` is a discriminator pattern; if no existing item satisfies it, + * a deep clone of the pattern is appended. + * + * Called in `createResource` so that required slices are always present even + * when the caller omits them. + */ +export const ensureSliceDefaults = (items: T[], ...matches: Record[]): T[] => { + for (const match of matches) { + if (!items.some((item) => matchesValue(item, match))) { + items.push(structuredClone(match) as T); + } + } + return items; +}; + +/** + * Add `canonicalUrl` to `resource.meta.profile` if not already present. + * Creates `meta` and `profile` when missing. + */ +export const ensureProfile = (resource: { meta?: { profile?: string[] } }, canonicalUrl: string): void => { + const meta = (resource.meta ??= {}); + const profiles = (meta.profile ??= []); + if (!profiles.includes(canonicalUrl)) profiles.push(canonicalUrl); +}; + +/** + * Find or insert a slice element in `list`. If an element matching `match` + * already exists it is replaced in place; otherwise `value` is appended. + */ +export const setArraySlice = (list: T[], match: Record, value: T): void => { + const index = list.findIndex((item) => matchesValue(item, match)); + if (index === -1) { + list.push(value); + } else { + list[index] = value; + } +}; -export const validateExcluded = (r: Record, field: string, path: string): string | undefined => { - return r[field] !== undefined ? `${path}: field '${field}' must not be present` : undefined; -} +/** Return the first element in `list` that satisfies the slice discriminator `match`. */ +export const getArraySlice = (list: readonly T[] | undefined, match: Record): T | undefined => { + if (!list) return undefined; + return list.find((item) => matchesValue(item, match)); +}; -export const validateFixedValue = (r: Record, field: string, expected: unknown, path: string): string | undefined => { - return matchesValue(r[field], expected) ? undefined : `${path}: field '${field}' does not match expected fixed value`; -} +// --------------------------------------------------------------------------- +// Validation helpers +// +// Each function returns an array of human-readable error strings (empty = ok). +// Profile classes spread them all into a single array from `validate()`. +// --------------------------------------------------------------------------- -export const validateSliceCardinality = (items: unknown[] | undefined, match: Record, sliceName: string, min: number, max: number, path: string): string[] => { - const count = (items ?? []).filter(item => matchesSlice(item, match)).length; +/** Checks that `field` is present (not `undefined` or `null`). */ +export const validateRequired = (res: Record, profileName: string, field: string): string[] => { + return res[field] === undefined || res[field] === null + ? [`${profileName}: required field '${field}' is missing`] + : []; +}; + +/** Checks that `field` is absent (profiles may exclude base fields). */ +export const validateExcluded = (res: Record, profileName: string, field: string): string[] => { + return res[field] !== undefined ? [`${profileName}: field '${field}' must not be present`] : []; +}; + +/** Checks that `field` structurally contains the expected fixed value. */ +export const validateFixedValue = ( + res: Record, + profileName: string, + field: string, + expected: unknown, +): string[] => { + return matchesValue(res[field], expected) + ? [] + : [`${profileName}: field '${field}' does not match expected fixed value`]; +}; + +/** + * Checks that the number of array elements matching `match` (a slice + * discriminator) falls within [`min`, `max`]. Pass `max = 0` for unbounded. + */ +export const validateSliceCardinality = ( + res: Record, + profileName: string, + field: string, + match: Record, + sliceName: string, + min: number, + max: number, +): string[] => { + const items = res[field] as unknown[] | undefined; + const count = (items ?? []).filter((item) => matchesValue(item, match)).length; const errors: string[] = []; if (count < min) { - errors.push(`${path}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); + errors.push(`${profileName}.${field}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`); } if (max > 0 && count > max) { - errors.push(`${path}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); + errors.push(`${profileName}.${field}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`); } return errors; -} +}; + +/** + * Checks that at least one of the listed choice-type variants is present. + * E.g. `["effectiveDateTime", "effectivePeriod"]`. + */ +export const validateChoiceRequired = ( + res: Record, + profileName: string, + choices: string[], +): string[] => { + return choices.some((c) => res[c] !== undefined) + ? [] + : [`${profileName}: at least one of ${choices.join(", ")} is required`]; +}; -export const validateEnum = (value: unknown, allowed: string[], field: string, path: string): string | undefined => { - if (value === undefined || value === null) return undefined; - if (typeof value === 'string') { - return allowed.includes(value) ? undefined : `${path}: field '${field}' value '${value}' is not in allowed values`; +/** + * Checks that the value of `field` has a code within `allowed`. + * Handles plain strings, Coding objects, and CodeableConcept objects. + * Skips validation when the field is absent. + */ +export const validateEnum = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; + if (typeof value === "string") { + return allowed.includes(value) + ? [] + : [`${profileName}: field '${field}' value '${value}' is not in allowed values`]; } const rec = value as Record; // Coding - if (typeof rec.code === 'string' && rec.system !== undefined) { - return allowed.includes(rec.code) ? undefined : `${path}: field '${field}' code '${rec.code}' is not in allowed values`; + if (typeof rec.code === "string" && rec.system !== undefined) { + return allowed.includes(rec.code) + ? [] + : [`${profileName}: field '${field}' code '${rec.code}' is not in allowed values`]; } // CodeableConcept if (Array.isArray(rec.coding)) { - const codes = (rec.coding as Array>).map(c => c.code as string).filter(Boolean); - const hasValid = codes.some(c => allowed.includes(c)); - return hasValid ? undefined : `${path}: field '${field}' has no coding with an allowed code`; + const codes = (rec.coding as Record[]).map((c) => c.code as string).filter(Boolean); + const hasValid = codes.some((c) => allowed.includes(c)); + return hasValid ? [] : [`${profileName}: field '${field}' has no coding with an allowed code`]; } - return undefined; -} + return []; +}; -export const validateReference = (value: unknown, allowed: string[], field: string, path: string): string | undefined => { - if (value === undefined || value === null) return undefined; +/** + * Checks that a Reference field points to one of the `allowed` resource + * types. Extracts the type from the `reference` string (the part before + * the first `/`). Skips validation when the field or reference is absent. + */ +export const validateReference = ( + res: Record, + profileName: string, + field: string, + allowed: string[], +): string[] => { + const value = res[field]; + if (value === undefined || value === null) return []; const ref = (value as Record).reference as string | undefined; - if (!ref) return undefined; - const slashIdx = ref.indexOf('/'); - if (slashIdx === -1) return undefined; + if (!ref) return []; + const slashIdx = ref.indexOf("/"); + if (slashIdx === -1) return []; const refType = ref.slice(0, slashIdx); - return allowed.includes(refType) ? undefined : `${path}: field '${field}' references '${refType}' but only ${allowed.join(', ')} are allowed`; -} + return allowed.includes(refType) + ? [] + : [`${profileName}: field '${field}' references '${refType}' but only ${allowed.join(", ")} are allowed`]; +}; diff --git a/src/api/writer-generator/typescript/name.ts b/src/api/writer-generator/typescript/name.ts index 71d9fdeb..aad27f8c 100644 --- a/src/api/writer-generator/typescript/name.ts +++ b/src/api/writer-generator/typescript/name.ts @@ -99,6 +99,8 @@ export const tsExtensionInputTypeName = (profileName: string, extensionName: str return `${uppercaseFirstLetter(profileName)}_${uppercaseFirstLetter(normalizeTsName(extensionName))}Input`; }; +export const tsSliceStaticName = (name: string): string => name.replace(/\[x\]/g, "").replace(/[^a-zA-Z0-9_$]/g, "_"); + export const tsSliceMethodName = (sliceName: string): string => { return `set${uppercaseFirstLetter(normalizeTsName(sliceName) || "Slice")}`; }; diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index d41ce336..3911da5b 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -34,6 +34,7 @@ import { tsResourceName, tsSliceInputTypeName, tsSliceMethodName, + tsSliceStaticName, } from "./name"; import { resolveFieldTsType, resolvePrimitiveType, tsEnumType, tsGet, tsTypeFromIdentifier } from "./utils"; import type { TypeScript } from "./writer"; @@ -41,7 +42,7 @@ import type { TypeScript } from "./writer"; type ProfileFactoryInfo = { autoFields: { name: string; value: string }[]; /** Array fields with required slices — optional param with auto-merge of required stubs */ - sliceAutoFields: { name: string; tsType: string; typeId: Identifier; defaultValue: string; matches: string[] }[]; + sliceAutoFields: { name: string; tsType: string; typeId: Identifier; sliceNames: string[] }[]; params: { name: string; tsType: string; typeId: Identifier }[]; accessors: { name: string; tsType: string; typeId: Identifier }[]; }; @@ -78,21 +79,22 @@ const tryPromoteChoice = ( promotedChoices.add(choiceName); }; -const collectRequiredSliceMatches = (field: RegularField): Record[] | undefined => { +const collectRequiredSliceNames = (field: RegularField): string[] | undefined => { if (!field.array || !field.slicing?.slices) return undefined; - const matches = Object.values(field.slicing.slices) - .filter((s) => s.min !== undefined && s.min >= 1 && s.match && Object.keys(s.match).length > 0) - .map((s) => s.match as Record); - return matches.length > 0 ? matches : undefined; + const names = Object.entries(field.slicing.slices) + .filter(([_, s]) => s.min !== undefined && s.min >= 1 && s.match && Object.keys(s.match).length > 0) + .map(([name]) => name); + return names.length > 0 ? names : undefined; }; -const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFactoryInfo => { +const collectProfileFactoryInfo = (tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema): ProfileFactoryInfo => { const autoFields: ProfileFactoryInfo["autoFields"] = []; const sliceAutoFields: ProfileFactoryInfo["sliceAutoFields"] = []; const params: ProfileFactoryInfo["params"] = []; const autoAccessors: ProfileFactoryInfo["accessors"] = []; const fields = flatProfile.fields ?? {}; const promotedChoices = new Set(); + const resolveRef = tsIndex.findLastSpecializationByIdentifier; if (isResourceIdentifier(flatProfile.base)) { autoFields.push({ name: "resourceType", value: JSON.stringify(flatProfile.base.name) }); @@ -111,24 +113,22 @@ const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFacto const value = JSON.stringify(field.valueConstraint.value); autoFields.push({ name, value: field.array ? `[${value}]` : value }); if (isNotChoiceDeclarationField(field) && field.type) { - const tsType = resolveFieldTsType("", "", field) + (field.array ? "[]" : ""); + const tsType = resolveFieldTsType("", "", field, resolveRef) + (field.array ? "[]" : ""); autoAccessors.push({ name, tsType, typeId: field.type }); } continue; } if (isNotChoiceDeclarationField(field)) { - const requiredMatches = collectRequiredSliceMatches(field); - if (requiredMatches) { - const defaultValue = `[${requiredMatches.map((m) => JSON.stringify(m)).join(",")}]`; + const sliceNames = collectRequiredSliceNames(field); + if (sliceNames) { if (field.type) { - const tsType = resolveFieldTsType("", "", field) + (field.array ? "[]" : ""); + const tsType = resolveFieldTsType("", "", field, resolveRef) + (field.array ? "[]" : ""); sliceAutoFields.push({ name, tsType, typeId: field.type, - defaultValue, - matches: requiredMatches.map((m) => JSON.stringify(m)), + sliceNames, }); autoAccessors.push({ name, tsType, typeId: field.type }); } @@ -137,11 +137,32 @@ const collectProfileFactoryInfo = (flatProfile: ProfileTypeSchema): ProfileFacto } if (field.required) { - const tsType = resolveFieldTsType("", "", field) + (field.array ? "[]" : ""); + const tsType = resolveFieldTsType("", "", field, resolveRef) + (field.array ? "[]" : ""); params.push({ name, tsType, typeId: field.type }); } } + // Include base-type required fields not already covered by profile constraints + const coveredFields = new Set([ + ...autoFields.map((f) => f.name), + ...sliceAutoFields.map((f) => f.name), + ...params.map((f) => f.name), + ...promotedChoices, + ]); + const baseSchema = tsIndex.resolve(flatProfile.base); + if (baseSchema && "fields" in baseSchema && baseSchema.fields) { + for (const [name, field] of Object.entries(baseSchema.fields)) { + if (coveredFields.has(name)) continue; + if (!field.required) continue; + if (isChoiceInstanceField(field)) continue; + if (isChoiceDeclarationField(field)) continue; + if (isNotChoiceDeclarationField(field) && field.type) { + const tsType = resolveFieldTsType("", "", field, resolveRef) + (field.array ? "[]" : ""); + params.push({ name, tsType, typeId: field.type }); + } + } + } + const accessors = [...autoAccessors, ...collectChoiceAccessors(flatProfile, promotedChoices)]; return { autoFields, sliceAutoFields, params, accessors }; }; @@ -242,324 +263,6 @@ const tsTypeForProfileField = ( return tsType; }; -export const generateProfileHelpersModule = (w: TypeScript) => { - w.cat("profile-helpers.ts", () => { - w.generateDisclaimer(); - w.curlyBlock( - ["export const", "isRecord", "=", "(value: unknown): value is Record", "=>"], - () => { - w.lineSM('return value !== null && typeof value === "object" && !Array.isArray(value)'); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "getOrCreateObjectAtPath", - "=", - "(root: Record, path: string[]): Record", - "=>", - ], - () => { - w.lineSM("let current: Record = root"); - w.curlyBlock(["for (const", "segment", "of", "path)"], () => { - w.curlyBlock(["if", "(Array.isArray(current[segment]))"], () => { - w.lineSM("const list = current[segment] as unknown[]"); - w.curlyBlock(["if", "(list.length === 0)"], () => { - w.lineSM("list.push({})"); - }); - w.lineSM("current = list[0] as Record"); - }); - w.curlyBlock(["else"], () => { - w.curlyBlock(["if", "(!isRecord(current[segment]))"], () => { - w.lineSM("current[segment] = {}"); - }); - w.lineSM("current = current[segment] as Record"); - }); - }); - w.lineSM("return current"); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "mergeMatch", - "=", - "(target: Record, match: Record): void", - "=>", - ], - () => { - w.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { - w.curlyBlock( - ["if", '(key === "__proto__" || key === "constructor" || key === "prototype")'], - () => { - w.lineSM("continue"); - }, - ); - w.curlyBlock(["if", "(isRecord(matchValue))"], () => { - w.curlyBlock(["if", "(isRecord(target[key]))"], () => { - w.lineSM("mergeMatch(target[key] as Record, matchValue)"); - }); - w.curlyBlock(["else"], () => { - w.lineSM("target[key] = { ...matchValue }"); - }); - }); - w.curlyBlock(["else"], () => { - w.lineSM("target[key] = matchValue"); - }); - }); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "applySliceMatch", - "=", - ">(input: T, match: Record): T", - "=>", - ], - () => { - w.lineSM("const result = { ...input } as Record"); - w.lineSM("mergeMatch(result, match)"); - w.lineSM("return result as T"); - }, - ); - w.line(); - w.curlyBlock(["export const", "matchesValue", "=", "(value: unknown, match: unknown): boolean", "=>"], () => { - w.curlyBlock(["if", "(Array.isArray(match))"], () => { - w.curlyBlock(["if", "(!Array.isArray(value))"], () => w.lineSM("return false")); - w.lineSM("return match.every((matchItem) => value.some((item) => matchesValue(item, matchItem)))"); - }); - w.curlyBlock(["if", "(isRecord(match))"], () => { - w.curlyBlock(["if", "(!isRecord(value))"], () => w.lineSM("return false")); - w.curlyBlock(["for (const", "[key, matchValue]", "of", "Object.entries(match))"], () => { - w.curlyBlock(["if", "(!matchesValue((value as Record)[key], matchValue))"], () => { - w.lineSM("return false"); - }); - }); - w.lineSM("return true"); - }); - w.lineSM("return value === match"); - }); - w.line(); - w.curlyBlock( - ["export const", "matchesSlice", "=", "(value: unknown, match: Record): boolean", "=>"], - () => { - w.lineSM("return matchesValue(value, match)"); - }, - ); - w.line(); - // extractComplexExtension - extract sub-extension values from complex extension - w.curlyBlock( - [ - "export const", - "extractComplexExtension", - "=", - "(extension: { extension?: Array<{ url?: string; [key: string]: unknown }> } | undefined, config: Array<{ name: string; valueField: string; isArray: boolean }>): Record | undefined", - "=>", - ], - () => { - w.lineSM("if (!extension?.extension) return undefined"); - w.lineSM("const result: Record = {}"); - w.curlyBlock(["for (const", "{ name, valueField, isArray }", "of", "config)"], () => { - w.lineSM("const subExts = extension.extension.filter(e => e.url === name)"); - w.curlyBlock(["if", "(isArray)"], () => { - w.lineSM("result[name] = subExts.map(e => (e as Record)[valueField])"); - }); - w.curlyBlock(["else if", "(subExts[0])"], () => { - w.lineSM("result[name] = (subExts[0] as Record)[valueField]"); - }); - }); - w.lineSM("return result"); - }, - ); - w.line(); - // extractSliceSimplified - remove match keys from slice (reverse of applySliceMatch) - w.curlyBlock( - [ - "export const", - "extractSliceSimplified", - "=", - ">(slice: T, matchKeys: string[]): Partial", - "=>", - ], - () => { - w.lineSM("const result = { ...slice } as Record"); - w.curlyBlock(["for (const", "key", "of", "matchKeys)"], () => { - w.lineSM("delete result[key]"); - }); - w.lineSM("return result as Partial"); - }, - ); - w.line(); - // wrapSliceChoice - wrap flat input under the single choice variant key for setter - w.curlyBlock( - [ - "export const", - "wrapSliceChoice", - "=", - "(input: Record, choiceVariant: string): Record", - "=>", - ], - () => { - w.lineSM("if (Object.keys(input).length === 0) return input"); - w.lineSM("return { [choiceVariant]: input }"); - }, - ); - w.line(); - // flattenSliceChoice - strip match keys and flatten choice variant into parent for getter - w.curlyBlock( - [ - "export const", - "flattenSliceChoice", - "=", - "(slice: Record, matchKeys: string[], choiceVariant: string): Record", - "=>", - ], - () => { - w.lineSM("const result = { ...slice } as Record"); - w.curlyBlock(["for (const", "key", "of", "matchKeys)"], () => { - w.lineSM("delete result[key]"); - }); - w.lineSM("const variantValue = result[choiceVariant]"); - w.lineSM("delete result[choiceVariant]"); - w.curlyBlock(["if", "(isRecord(variantValue))"], () => { - w.lineSM("Object.assign(result, variantValue)"); - }); - w.lineSM("return result"); - }, - ); - w.line(); - // --- Validation helpers --- - w.curlyBlock( - [ - "export const", - "validateRequired", - "=", - "(r: Record, field: string, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM( - "return r[field] === undefined || r[field] === null ? `${path}: required field '${field}' is missing` : undefined", - ); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateExcluded", - "=", - "(r: Record, field: string, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM("return r[field] !== undefined ? `${path}: field '${field}' must not be present` : undefined"); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateFixedValue", - "=", - "(r: Record, field: string, expected: unknown, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM( - "return matchesValue(r[field], expected) ? undefined : `${path}: field '${field}' does not match expected fixed value`", - ); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateSliceCardinality", - "=", - "(items: unknown[] | undefined, match: Record, sliceName: string, min: number, max: number, path: string): string[]", - "=>", - ], - () => { - w.lineSM("const count = (items ?? []).filter(item => matchesSlice(item, match)).length"); - w.lineSM("const errors: string[] = []"); - w.curlyBlock(["if", "(count < min)"], () => { - w.lineSM( - "errors.push(`${path}: slice '${sliceName}' requires at least ${min} item(s), found ${count}`)", - ); - }); - w.curlyBlock(["if", "(max > 0 && count > max)"], () => { - w.lineSM( - "errors.push(`${path}: slice '${sliceName}' allows at most ${max} item(s), found ${count}`)", - ); - }); - w.lineSM("return errors"); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateEnum", - "=", - "(value: unknown, allowed: string[], field: string, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM("if (value === undefined || value === null) return undefined"); - w.curlyBlock(["if", "(typeof value === 'string')"], () => { - w.lineSM( - "return allowed.includes(value) ? undefined : `${path}: field '${field}' value '${value}' is not in allowed values`", - ); - }); - w.lineSM("const rec = value as Record"); - w.comment("Coding"); - w.curlyBlock(["if", "(typeof rec.code === 'string' && rec.system !== undefined)"], () => { - w.lineSM( - "return allowed.includes(rec.code) ? undefined : `${path}: field '${field}' code '${rec.code}' is not in allowed values`", - ); - }); - w.comment("CodeableConcept"); - w.curlyBlock(["if", "(Array.isArray(rec.coding))"], () => { - w.lineSM( - "const codes = (rec.coding as Array>).map(c => c.code as string).filter(Boolean)", - ); - w.lineSM("const hasValid = codes.some(c => allowed.includes(c))"); - w.lineSM( - "return hasValid ? undefined : `${path}: field '${field}' has no coding with an allowed code`", - ); - }); - w.lineSM("return undefined"); - }, - ); - w.line(); - w.curlyBlock( - [ - "export const", - "validateReference", - "=", - "(value: unknown, allowed: string[], field: string, path: string): string | undefined", - "=>", - ], - () => { - w.lineSM("if (value === undefined || value === null) return undefined"); - w.lineSM("const ref = (value as Record).reference as string | undefined"); - w.lineSM("if (!ref) return undefined"); - w.lineSM("const slashIdx = ref.indexOf('/')"); - w.lineSM("if (slashIdx === -1) return undefined"); - w.lineSM("const refType = ref.slice(0, slashIdx)"); - w.lineSM( - "return allowed.includes(refType) ? undefined : `${path}: field '${field}' references '${refType}' but only ${allowed.join(', ')} are allowed`", - ); - }, - ); - }); -}; - const generateProfileHelpersImport = ( w: TypeScript, options: { @@ -569,25 +272,18 @@ const generateProfileHelpersImport = ( needsSliceExtraction: boolean; needsSliceChoiceHelpers: boolean; needsValidation: boolean; + needsRegisterProfile: boolean; }, ) => { const imports: string[] = []; - if (options.needsSliceHelpers) { - imports.push("applySliceMatch", "matchesSlice"); - } - if (options.needsGetOrCreateObjectAtPath) { - imports.push("getOrCreateObjectAtPath"); - } - if (options.needsExtensionExtraction) { - imports.push("extractComplexExtension"); - } - if (options.needsSliceExtraction) { - imports.push("extractSliceSimplified"); - } - if (options.needsSliceChoiceHelpers) { - imports.push("wrapSliceChoice", "flattenSliceChoice"); - } - if (options.needsValidation) { + if (options.needsRegisterProfile) imports.push("ensureProfile"); + if (options.needsSliceHelpers) + imports.push("applySliceMatch", "matchesValue", "setArraySlice", "getArraySlice", "ensureSliceDefaults"); + if (options.needsGetOrCreateObjectAtPath) imports.push("ensurePath"); + if (options.needsExtensionExtraction) imports.push("extractComplexExtension"); + if (options.needsSliceExtraction) imports.push("stripMatchKeys"); + if (options.needsSliceChoiceHelpers) imports.push("wrapSliceChoice", "unwrapSliceChoice"); + if (options.needsValidation) imports.push( "validateRequired", "validateExcluded", @@ -595,11 +291,9 @@ const generateProfileHelpersImport = ( "validateSliceCardinality", "validateEnum", "validateReference", + "validateChoiceRequired", ); - } - if (imports.length > 0) { - w.lineSM(`import { ${imports.join(", ")} } from "../../profile-helpers"`); - } + if (imports.length > 0) w.lineSM(`import { ${imports.join(", ")} } from "../../profile-helpers"`); }; const collectTypesFromSlices = ( @@ -703,7 +397,7 @@ export const generateProfileImports = (w: TypeScript, tsIndex: TypeSchemaIndex, const needsExtensionType = collectTypesFromExtensions(tsIndex, flatProfile, addType); collectTypesFromFieldOverrides(tsIndex, flatProfile, addType); - const factoryInfo = collectProfileFactoryInfo(flatProfile); + const factoryInfo = collectProfileFactoryInfo(tsIndex, flatProfile); for (const param of factoryInfo.params) addType(param.typeId); for (const f of factoryInfo.sliceAutoFields) addType(f.typeId); for (const accessor of factoryInfo.accessors) addType(accessor.typeId); @@ -908,7 +602,7 @@ export const generateProfileClass = ( // Check if we have an override interface (narrowed types) const hasOverrideInterface = detectFieldOverrides(w, tsIndex, flatProfile).size > 0; - const factoryInfo = collectProfileFactoryInfo(flatProfile); + const factoryInfo = collectProfileFactoryInfo(tsIndex, flatProfile); // Determine which helpers are actually needed const needsSliceHelpers = sliceDefs.length > 0 || factoryInfo.sliceAutoFields.length > 0; @@ -922,6 +616,8 @@ export const generateProfileClass = ( const needsSliceChoiceHelpers = sliceDefs.some((s) => s.constrainedChoice); const needsValidation = Object.keys(flatProfile.fields ?? {}).length > 0; + const hasMeta = tsIndex.isWithMetaField(flatProfile); + const needsRegisterProfile = !!schema?.identifier.url && hasMeta; if ( needsSliceHelpers || @@ -929,7 +625,8 @@ export const generateProfileClass = ( needsExtensionExtraction || needsSliceExtraction || needsSliceChoiceHelpers || - needsValidation + needsValidation || + needsRegisterProfile ) { generateProfileHelpersImport(w, { needsGetOrCreateObjectAtPath, @@ -938,6 +635,7 @@ export const generateProfileClass = ( needsSliceExtraction, needsSliceChoiceHelpers, needsValidation, + needsRegisterProfile, }); w.line(); } @@ -973,17 +671,19 @@ export const generateProfileClass = ( w.line(`static readonly canonicalUrl = ${JSON.stringify(canonicalUrl)}`); w.line(); } + for (const sliceDef of sliceDefs) { + const staticName = `${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`; + w.line( + `private static readonly ${staticName}: Record = ${JSON.stringify(sliceDef.match)}`, + ); + } + if (sliceDefs.length > 0) w.line(); w.line(`private resource: ${tsBaseResourceName}`); w.line(); w.curlyBlock(["constructor", `(resource: ${tsBaseResourceName})`], () => { w.line("this.resource = resource"); - if (canonicalUrl && isResourceIdentifier(flatProfile.base)) { - w.line(`const r = resource as unknown as Record`); - w.line(`const meta = (r.meta ??= {}) as Record`); - w.line(`const profiles = (meta.profile ??= []) as string[]`); - w.line( - `if (!profiles.includes(${JSON.stringify(canonicalUrl)})) profiles.push(${JSON.stringify(canonicalUrl)})`, - ); + if (canonicalUrl && hasMeta) { + w.line(`ensureProfile(resource, ${JSON.stringify(canonicalUrl)})`); } }); w.line(); @@ -994,23 +694,31 @@ export const generateProfileClass = ( w.curlyBlock(["static", "createResource", `(${paramSignature})`, `: ${tsBaseResourceName}`], () => { // Generate merge logic for slice auto-fields for (const f of factoryInfo.sliceAutoFields) { - const matchExprs = f.matches.map((m) => `${m} as Record`); - w.line(`const ${f.name}Defaults = ${f.defaultValue} as unknown[]`); - w.line(`const ${f.name}WithDefaults = [...(args.${f.name} ?? [])] as unknown[]`); - for (let i = 0; i < matchExprs.length; i++) { - w.line( - `if (!${f.name}WithDefaults.some(item => matchesSlice(item, ${matchExprs[i]}))) ${f.name}WithDefaults.push(${f.name}Defaults[${i}]!)`, - ); - } + const matchRefs = f.sliceNames.map((s) => `${profileClassName}.${tsSliceStaticName(s)}SliceMatch`); + w.line(`const ${f.name}WithDefaults = ensureSliceDefaults(`); + w.indentBlock(() => { + w.line(`[...(args.${f.name} ?? [])],`); + for (const ref of matchRefs) { + w.line(`${ref},`); + } + }); + w.line(")"); + } + if (factoryInfo.sliceAutoFields.length > 0) { + w.line(); + } + if (isPrimitiveIdentifier(flatProfile.base)) { + w.line(`const resource = undefined as unknown as ${tsBaseResourceName}`); + } else { + w.curlyBlock(["const resource ="], () => { + for (const f of allFields) { + w.line(`${f.name}: ${f.value},`); + } + if (canonicalUrl && hasMeta) { + w.line(`meta: { profile: [${profileClassName}.canonicalUrl] },`); + } + }, [` as unknown as ${tsBaseResourceName}`]); } - w.curlyBlock([`const resource: ${tsBaseResourceName} =`], () => { - for (const f of allFields) { - w.line(`${f.name}: ${f.value},`); - } - if (canonicalUrl && isResourceIdentifier(flatProfile.base)) { - w.line(`meta: { profile: [${JSON.stringify(canonicalUrl)}] },`); - } - }, [` as unknown as ${tsBaseResourceName}`]); w.line("return resource"); }); w.line(); @@ -1023,7 +731,12 @@ export const generateProfileClass = ( w.line("return this.resource"); }); w.line(); - // Getter and setter methods for required profile fields + // -- Field accessors section -- + const hasFieldAccessors = factoryInfo.params.length > 0 || factoryInfo.accessors.length > 0; + if (hasFieldAccessors) { + w.line("// Field accessors"); + w.line(); + } for (const p of factoryInfo.params) { const methodSuffix = uppercaseFirstLetter(p.name); w.curlyBlock([`get${methodSuffix}`, "()", `: ${p.tsType} | undefined`], () => { @@ -1089,13 +802,20 @@ export const generateProfileClass = ( w.line(); } + // -- Slices and extensions section -- + const hasSlicesOrExtensions = extensions.length > 0 || sliceDefs.length > 0; + if (hasSlicesOrExtensions) { + w.line("// Slices and extensions"); + w.line(); + } + generateExtensionSetterMethods(w, extensions, extensionMethodNames, tsProfileName); for (const sliceDef of sliceDefs) { const methodName = sliceMethodNames.get(sliceDef) ?? tsQualifiedSliceMethodName(sliceDef.fieldName, sliceDef.sliceName); const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); - const matchLiteral = JSON.stringify(sliceDef.match); + const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`; const tsField = tsFieldName(sliceDef.fieldName); const fieldAccess = tsGet("this.resource", tsField); // Make input optional when there are no required fields (input can be empty object) @@ -1103,31 +823,19 @@ export const generateProfileClass = ( ? `(input?: ${typeName}): this` : `(input: ${typeName}): this`; w.curlyBlock(["public", methodName, paramSignature], () => { - w.line(`const match = ${matchLiteral} as Record`); - // Use empty object as default when input is optional - const inputExpr = sliceDef.inputOptional - ? "(input ?? {}) as Record" - : "input as Record"; + w.line(`const match = ${matchRef}`); + const inputExpr = sliceDef.inputOptional ? "input ?? {}" : "input"; if (sliceDef.constrainedChoice) { const cc = sliceDef.constrainedChoice; w.line( - `const value = applySliceMatch(wrapSliceChoice(${inputExpr}, ${JSON.stringify(cc.variant)}), match) as unknown as ${sliceDef.baseType}`, + `const wrapped = wrapSliceChoice<${sliceDef.baseType}>(${inputExpr}, ${JSON.stringify(cc.variant)})`, ); + w.line(`const value = applySliceMatch<${sliceDef.baseType}>(wrapped, match)`); } else { - w.line(`const value = applySliceMatch(${inputExpr}, match) as unknown as ${sliceDef.baseType}`); + w.line(`const value = applySliceMatch<${sliceDef.baseType}>(${inputExpr}, match)`); } if (sliceDef.array) { - w.line(`const list = (${fieldAccess} ??= [])`); - w.line("const index = list.findIndex((item) => matchesSlice(item, match))"); - w.line("if (index === -1) {"); - w.indentBlock(() => { - w.line("list.push(value)"); - }); - w.line("} else {"); - w.indentBlock(() => { - w.line("list[index] = value"); - }); - w.line("}"); + w.line(`setArraySlice(${fieldAccess} ??= [], match, value)`); } else { w.line(`${fieldAccess} = value`); } @@ -1157,7 +865,7 @@ export const generateProfileClass = ( w.line(`const ext = this.resource.extension?.find(e => e.url === "${ext.url}")`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, ); w.line( `const ext = (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, @@ -1212,7 +920,7 @@ export const generateProfileClass = ( w.line(`return this.resource.extension?.find(e => e.url === "${ext.url}")`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, ); w.line( `return (target.extension as Extension[] | undefined)?.find(e => e.url === "${ext.url}")`, @@ -1233,7 +941,7 @@ export const generateProfileClass = ( if (generatedGetMethods.has(getMethodName)) continue; generatedGetMethods.add(getMethodName); const typeName = tsSliceInputTypeName(tsProfileName, sliceDef.fieldName, sliceDef.sliceName); - const matchLiteral = JSON.stringify(sliceDef.match); + const matchRef = `${profileClassName}.${tsSliceStaticName(sliceDef.sliceName)}SliceMatch`; const matchKeys = JSON.stringify(Object.keys(sliceDef.match)); const tsField = tsFieldName(sliceDef.fieldName); const fieldAccess = tsGet("this.resource", tsField); @@ -1241,14 +949,12 @@ export const generateProfileClass = ( // Helper to find the slice item const generateSliceLookup = () => { - w.line(`const match = ${matchLiteral} as Record`); + w.line(`const match = ${matchRef}`); if (sliceDef.array) { - w.line(`const list = ${fieldAccess}`); - w.line("if (!list) return undefined"); - w.line("const item = list.find((item) => matchesSlice(item, match))"); + w.line(`const item = getArraySlice(${fieldAccess}, match)`); } else { w.line(`const item = ${fieldAccess}`); - w.line("if (!item || !matchesSlice(item, match)) return undefined"); + w.line("if (!item || !matchesValue(item, match)) return undefined"); } }; @@ -1260,13 +966,9 @@ export const generateProfileClass = ( } if (sliceDef.constrainedChoice) { const cc = sliceDef.constrainedChoice; - w.line( - `return flattenSliceChoice(item as unknown as Record, ${matchKeys}, ${JSON.stringify(cc.variant)}) as ${typeName}`, - ); + w.line(`return unwrapSliceChoice<${typeName}>(item, ${matchKeys}, ${JSON.stringify(cc.variant)})`); } else { - w.line( - `return extractSliceSimplified(item as unknown as Record, ${matchKeys}) as ${typeName}`, - ); + w.line(`return stripMatchKeys<${typeName}>(item, ${matchKeys})`); } }); w.line(); @@ -1274,55 +976,46 @@ export const generateProfileClass = ( // Raw getter (full FHIR type) w.curlyBlock(["public", getRawMethodName, `(): ${baseType} | undefined`], () => { generateSliceLookup(); - if (sliceDef.array) { - w.line("return item"); - } else { - w.line("return item"); - } + w.line("return item"); }); w.line(); } - generateValidateMethod(w, flatProfile); + // -- Validation section -- + if (needsValidation) { + w.line("// Validation"); + w.line(); + } + generateValidateMethod(w, tsIndex, flatProfile); }); w.line(); }; -const emitRegularFieldValidation = ( - w: TypeScript, +const collectRegularFieldValidation = ( + exprs: string[], name: string, field: RegularField | ChoiceFieldInstance, - profileName: string, + resolveRef: (ref: Identifier) => Identifier, ) => { if (field.excluded) { - w.line(`{ const e = validateExcluded(r, ${JSON.stringify(name)}, "${profileName}"); if (e) errors.push(e) }`); + exprs.push(`...validateExcluded(res, profileName, ${JSON.stringify(name)})`); return; } - if (field.required) { - w.line(`{ const e = validateRequired(r, ${JSON.stringify(name)}, "${profileName}"); if (e) errors.push(e) }`); - } + if (field.required) exprs.push(`...validateRequired(res, profileName, ${JSON.stringify(name)})`); - if (field.valueConstraint) { - const expected = JSON.stringify(field.valueConstraint.value); - w.line( - `{ const e = validateFixedValue(r, ${JSON.stringify(name)}, ${expected}, "${profileName}"); if (e) errors.push(e) }`, + if (field.valueConstraint) + exprs.push( + `...validateFixedValue(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.valueConstraint.value)})`, ); - } - if (field.enum && !field.enum.isOpen) { - const values = JSON.stringify(field.enum.values); - w.line( - `{ const e = validateEnum(r[${JSON.stringify(name)}], ${values}, ${JSON.stringify(name)}, "${profileName}"); if (e) errors.push(e) }`, - ); - } + if (field.enum && !field.enum.isOpen) + exprs.push(`...validateEnum(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.enum.values)})`); - if (field.reference && field.reference.length > 0) { - const refNames = JSON.stringify(field.reference.map((ref) => ref.name)); - w.line( - `{ const e = validateReference(r[${JSON.stringify(name)}], ${refNames}, ${JSON.stringify(name)}, "${profileName}"); if (e) errors.push(e) }`, + if (field.reference && field.reference.length > 0) + exprs.push( + `...validateReference(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(field.reference.map((ref) => resolveRef(ref).name))})`, ); - } if (field.slicing?.slices) { for (const [sliceName, slice] of Object.entries(field.slicing.slices)) { @@ -1331,36 +1024,40 @@ const emitRegularFieldValidation = ( if (Object.keys(match).length === 0) continue; const min = slice.min ?? 0; const max = slice.max ?? 0; - w.line( - `errors.push(...validateSliceCardinality(r[${JSON.stringify(name)}] as unknown[] | undefined, ${JSON.stringify(match)}, ${JSON.stringify(sliceName)}, ${min}, ${max}, "${profileName}.${name}"))`, + exprs.push( + `...validateSliceCardinality(res, profileName, ${JSON.stringify(name)}, ${JSON.stringify(match)}, ${JSON.stringify(sliceName)}, ${min}, ${max})`, ); } } }; -const generateValidateMethod = (w: TypeScript, flatProfile: ProfileTypeSchema) => { +const generateValidateMethod = (w: TypeScript, tsIndex: TypeSchemaIndex, flatProfile: ProfileTypeSchema) => { const fields = flatProfile.fields ?? {}; const profileName = flatProfile.identifier.name; - w.curlyBlock(["validate", "()", ": string[]"], () => { - w.line("const errors: string[] = []"); - w.line("const r = this.resource as unknown as Record"); + w.curlyBlock(["validate(): string[]"], () => { + w.line(`const profileName = "${profileName}"`); + w.line("const res = this.resource as unknown as Record"); + + const exprs: string[] = []; for (const [name, field] of Object.entries(fields)) { if (isChoiceInstanceField(field)) continue; if (isChoiceDeclarationField(field)) { - if (field.required) { - const choiceNames = field.choices; - const checks = choiceNames.map((c) => `r[${JSON.stringify(c)}] !== undefined`).join(" || "); - w.curlyBlock(["if", `(!(${checks}))`], () => { - w.line(`errors.push("${name}: at least one of ${choiceNames.join(", ")} is required")`); - }); - } + if (field.required) + exprs.push(`...validateChoiceRequired(res, profileName, ${JSON.stringify(field.choices)})`); continue; } - emitRegularFieldValidation(w, name, field, profileName); + collectRegularFieldValidation(exprs, name, field, tsIndex.findLastSpecializationByIdentifier); + } + + if (exprs.length === 0) { + w.line("return []"); + } else { + w.squareBlock(["return"], () => { + for (const expr of exprs) w.line(`${expr},`); + }); } - w.line("return errors"); }); w.line(); }; @@ -1405,7 +1102,7 @@ const generateExtensionSetterMethods = ( w.line(`list.push({ url: "${ext.url}", extension: subExtensions })`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify(targetPath)})`, ); w.line("if (!Array.isArray(target.extension)) target.extension = [] as Extension[]"); w.line(`(target.extension as Extension[]).push({ url: "${ext.url}", extension: subExtensions })`); @@ -1424,7 +1121,7 @@ const generateExtensionSetterMethods = ( w.line(`list.push(${extLiteral})`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify( targetPath, )})`, ); @@ -1440,7 +1137,7 @@ const generateExtensionSetterMethods = ( w.line(`list.push({ url: "${ext.url}", ...value })`); } else { w.line( - `const target = getOrCreateObjectAtPath(this.resource as unknown as Record, ${JSON.stringify( + `const target = ensurePath(this.resource as unknown as Record, ${JSON.stringify( targetPath, )})`, ); diff --git a/src/api/writer-generator/typescript/utils.ts b/src/api/writer-generator/typescript/utils.ts index aa9e1382..aec1de7f 100644 --- a/src/api/writer-generator/typescript/utils.ts +++ b/src/api/writer-generator/typescript/utils.ts @@ -62,6 +62,7 @@ export const resolveFieldTsType = ( schemaName: string, tsName: string, field: RegularField | ChoiceFieldInstance, + resolveRef?: (ref: Identifier) => Identifier, ): string => { const rewriteFieldType = rewriteFieldTypeDefs[schemaName]?.[tsName]; if (rewriteFieldType) return rewriteFieldType(); @@ -72,7 +73,10 @@ export const resolveFieldTsType = ( return tsEnumType(field.enum); } if (field.reference && field.reference.length > 0) { - const references = field.reference.map((ref) => `"${ref.name}"`).join(" | "); + const references = field.reference + .map((ref) => (resolveRef ? resolveRef(ref) : ref)) + .map((ref) => `"${ref.name}"`) + .join(" | "); return `Reference<${references}>`; } if (isPrimitiveIdentifier(field.type)) return resolvePrimitiveType(field.type.name); diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index 939c13e4..70c01914 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -1,3 +1,5 @@ +import * as Path from "node:path"; +import { fileURLToPath } from "node:url"; import { Writer, type WriterOptions } from "@root/api/writer-generator/writer"; import { type CanonicalUrl, @@ -28,13 +30,21 @@ import { } from "./name"; import { generateProfileClass, - generateProfileHelpersModule, generateProfileImports, generateProfileIndexFile, generateProfileOverrideInterface, } from "./profile"; import { resolveFieldTsType } from "./utils"; +export const resolveTsAssets = (fn: string) => { + const __dirname = Path.dirname(fileURLToPath(import.meta.url)); + const __filename = fileURLToPath(import.meta.url); + if (__filename.endsWith("dist/index.js")) { + return Path.resolve(__dirname, "..", "assets", "api", "writer-generator", "typescript", fn); + } + return Path.resolve(__dirname, "../../../..", "assets", "api", "writer-generator", "typescript", fn); +}; + export type TypeScriptOptions = { /** openResourceTypeSet -- for resource families (Resource, DomainResource) use open set for resourceType field. * @@ -46,6 +56,10 @@ export type TypeScriptOptions = { } & WriterOptions; export class TypeScript extends Writer { + constructor(options: TypeScriptOptions) { + super({ ...options, resolveAssets: options.resolveAssets ?? resolveTsAssets }); + } + tsImportType(tsPackageName: string, ...entities: string[]) { this.lineSM(`import type { ${entities.join(", ")} } from "${tsPackageName}"`); } @@ -296,7 +310,7 @@ export class TypeScript extends Writer { this.cd("/", () => { if (hasProfiles) { - generateProfileHelpersModule(this); + this.cp("profile-helpers.ts", "profile-helpers.ts"); } for (const [packageName, packageSchemas] of Object.entries(grouped)) { diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index c100b926..59f84629 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -329,7 +329,7 @@ export interface observation_bodyweight extends Observation { export type Observation_bodyweight_Category_VSCatSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_bodyweightProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -341,14 +341,13 @@ export type observation_bodyweightProfileParams = { export class observation_bodyweightProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/bodyweight" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/bodyweight")) profiles.push("http://hl7.org/fhir/StructureDefinition/bodyweight") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/bodyweight") } static from (resource: Observation) : observation_bodyweightProfile { @@ -356,16 +355,18 @@ export class observation_bodyweightProfile { } static createResource (args: observation_bodyweightProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_bodyweightProfile.VSCatSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, category: categoryWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bodyweight"] }, + meta: { profile: [observation_bodyweightProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -378,6 +379,8 @@ export class observation_bodyweightProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -445,53 +448,46 @@ export class observation_bodyweightProfile { return this.resource as observation_bodyweight } + // Slices and extensions + public setVSCat (input?: Observation_bodyweight_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bodyweightProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public getVSCat (): Observation_bodyweight_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bodyweightProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_bodyweight_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bodyweightProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-bodyweight"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-bodyweight.category")) - { const e = validateRequired(r, "code", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}, "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-bodyweight"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-bodyweight"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-bodyweight"); if (e) errors.push(e) } - return errors + // Validation + + validate(): string[] { + const profileName = "observation-bodyweight" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"code":"29463-7","system":"http://loinc.org"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ] } } @@ -520,7 +516,7 @@ export type Observation_bp_Category_VSCatSliceInput = Omit; export type Observation_bp_Component_DiastolicBPSliceInput = Omit; -import { applySliceMatch, matchesSlice, extractSliceSimplified, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference } from "../../profile-helpers"; +import { ensureProfile, applySliceMatch, matchesValue, setArraySlice, getArraySlice, ensureSliceDefaults, stripMatchKeys, validateRequired, validateExcluded, validateFixedValue, validateSliceCardinality, validateEnum, validateReference, validateChoiceRequired } from "../../profile-helpers"; export type observation_bpProfileParams = { status: ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown"); @@ -533,14 +529,15 @@ export type observation_bpProfileParams = { export class observation_bpProfile { static readonly canonicalUrl = "http://hl7.org/fhir/StructureDefinition/bp" + private static readonly VSCatSliceMatch: Record = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} + private static readonly SystolicBPSliceMatch: Record = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} + private static readonly DiastolicBPSliceMatch: Record = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} + private resource: Observation constructor (resource: Observation) { this.resource = resource - const r = resource as unknown as Record - const meta = (r.meta ??= {}) as Record - const profiles = (meta.profile ??= []) as string[] - if (!profiles.includes("http://hl7.org/fhir/StructureDefinition/bp")) profiles.push("http://hl7.org/fhir/StructureDefinition/bp") + ensureProfile(resource, "http://hl7.org/fhir/StructureDefinition/bp") } static from (resource: Observation) : observation_bpProfile { @@ -548,21 +545,24 @@ export class observation_bpProfile { } static createResource (args: observation_bpProfileParams) : Observation { - const categoryDefaults = [{"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}] as unknown[] - const categoryWithDefaults = [...(args.category ?? [])] as unknown[] - if (!categoryWithDefaults.some(item => matchesSlice(item, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record))) categoryWithDefaults.push(categoryDefaults[0]!) - const componentDefaults = [{"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}},{"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}] as unknown[] - const componentWithDefaults = [...(args.component ?? [])] as unknown[] - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record))) componentWithDefaults.push(componentDefaults[0]!) - if (!componentWithDefaults.some(item => matchesSlice(item, {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record))) componentWithDefaults.push(componentDefaults[1]!) - const resource: Observation = { + const categoryWithDefaults = ensureSliceDefaults( + [...(args.category ?? [])], + observation_bpProfile.VSCatSliceMatch, + ) + const componentWithDefaults = ensureSliceDefaults( + [...(args.component ?? [])], + observation_bpProfile.SystolicBPSliceMatch, + observation_bpProfile.DiastolicBPSliceMatch, + ) + + const resource = { resourceType: "Observation", code: {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, category: categoryWithDefaults, component: componentWithDefaults, status: args.status, subject: args.subject, - meta: { profile: ["http://hl7.org/fhir/StructureDefinition/bp"] }, + meta: { profile: [observation_bpProfile.canonicalUrl] }, } as unknown as Observation return resource } @@ -575,6 +575,8 @@ export class observation_bpProfile { return this.resource } + // Field accessors + getStatus () : ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined { return this.resource.status as ("registered" | "preliminary" | "final" | "amended" | "corrected" | "cancelled" | "entered-in-error" | "unknown") | undefined } @@ -651,115 +653,88 @@ export class observation_bpProfile { return this.resource as observation_bp } + // Slices and extensions + public setVSCat (input?: Observation_bp_Category_VSCatSliceInput): this { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as CodeableConcept - const list = (this.resource.category ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.VSCatSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.category ??= [], match, value) return this } public setSystolicBP (input?: Observation_bp_Component_SystolicBPSliceInput): this { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.SystolicBPSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public setDiastolicBP (input?: Observation_bp_Component_DiastolicBPSliceInput): this { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const value = applySliceMatch((input ?? {}) as Record, match) as unknown as ObservationComponent - const list = (this.resource.component ??= []) - const index = list.findIndex((item) => matchesSlice(item, match)) - if (index === -1) { - list.push(value) - } else { - list[index] = value - } + const match = observation_bpProfile.DiastolicBPSliceMatch + const value = applySliceMatch(input ?? {}, match) + setArraySlice(this.resource.component ??= [], match, value) return this } public getVSCat (): Observation_bp_Category_VSCatSliceInput | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["coding"]) as Observation_bp_Category_VSCatSliceInput + return stripMatchKeys(item, ["coding"]) } public getVSCatRaw (): CodeableConcept | undefined { - const match = {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}} as Record - const list = this.resource.category - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.VSCatSliceMatch + const item = getArraySlice(this.resource.category, match) return item } public getSystolicBP (): Observation_bp_Component_SystolicBPSliceInput | undefined { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.SystolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["code"]) as Observation_bp_Component_SystolicBPSliceInput + return stripMatchKeys(item, ["code"]) } public getSystolicBPRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.SystolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) return item } public getDiastolicBP (): Observation_bp_Component_DiastolicBPSliceInput | undefined { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.DiastolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) if (!item) return undefined - return extractSliceSimplified(item as unknown as Record, ["code"]) as Observation_bp_Component_DiastolicBPSliceInput + return stripMatchKeys(item, ["code"]) } public getDiastolicBPRaw (): ObservationComponent | undefined { - const match = {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}} as Record - const list = this.resource.component - if (!list) return undefined - const item = list.find((item) => matchesSlice(item, match)) + const match = observation_bpProfile.DiastolicBPSliceMatch + const item = getArraySlice(this.resource.component, match) return item } - validate () : string[] { - const errors: string[] = [] - const r = this.resource as unknown as Record - { const e = validateRequired(r, "status", "observation-bp"); if (e) errors.push(e) } - { const e = validateEnum(r["status"], ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"], "status", "observation-bp"); if (e) errors.push(e) } - { const e = validateRequired(r, "category", "observation-bp"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["category"] as unknown[] | undefined, {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1, "observation-bp.category")) - { const e = validateRequired(r, "code", "observation-bp"); if (e) errors.push(e) } - { const e = validateFixedValue(r, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}, "observation-bp"); if (e) errors.push(e) } - { const e = validateRequired(r, "subject", "observation-bp"); if (e) errors.push(e) } - { const e = validateReference(r["subject"], ["Patient"], "subject", "observation-bp"); if (e) errors.push(e) } - if (!(r["effectiveDateTime"] !== undefined || r["effectivePeriod"] !== undefined)) { - errors.push("effective: at least one of effectiveDateTime, effectivePeriod is required") - } - { const e = validateReference(r["hasMember"], ["MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "hasMember", "observation-bp"); if (e) errors.push(e) } - { const e = validateReference(r["derivedFrom"], ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","observation-vitalsigns"], "derivedFrom", "observation-bp"); if (e) errors.push(e) } - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}}, "SystolicBP", 1, 1, "observation-bp.component")) - errors.push(...validateSliceCardinality(r["component"] as unknown[] | undefined, {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}, "DiastolicBP", 1, 1, "observation-bp.component")) - return errors + // Validation + + validate(): string[] { + const profileName = "observation-bp" + const res = this.resource as unknown as Record + return [ + ...validateRequired(res, profileName, "status"), + ...validateEnum(res, profileName, "status", ["registered","preliminary","final","amended","corrected","cancelled","entered-in-error","unknown"]), + ...validateRequired(res, profileName, "category"), + ...validateSliceCardinality(res, profileName, "category", {"coding":{"code":"vital-signs","system":"http://terminology.hl7.org/CodeSystem/observation-category"}}, "VSCat", 1, 1), + ...validateRequired(res, profileName, "code"), + ...validateFixedValue(res, profileName, "code", {"coding":[{"code":"85354-9","system":"http://loinc.org"}]}), + ...validateRequired(res, profileName, "subject"), + ...validateReference(res, profileName, "subject", ["Patient"]), + ...validateChoiceRequired(res, profileName, ["effectiveDateTime","effectivePeriod"]), + ...validateReference(res, profileName, "hasMember", ["MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateReference(res, profileName, "derivedFrom", ["DocumentReference","ImagingStudy","Media","MolecularSequence","QuestionnaireResponse","Observation"]), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":{"code":"8480-6","system":"http://loinc.org"}}}, "SystolicBP", 1, 1), + ...validateSliceCardinality(res, profileName, "component", {"code":{"coding":{"code":"8462-4","system":"http://loinc.org"}}}, "DiastolicBP", 1, 1), + ] } }