@peerbits/fhir-validator 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ import { validatePatient } from "./base-definitions/patient.js";
2
+ import { validateObservation } from "./base-definitions/observation.js";
3
+ import { validateEncounter } from "./base-definitions/encounter.js";
4
+ import { validateCondition } from "./base-definitions/condition.js";
5
+ import { validateCoverage } from "./base-definitions/coverage.js";
6
+ import { validateClaim } from "./base-definitions/claim.js";
7
+ import { validateClaimResponse } from "./base-definitions/claim-response.js";
8
+ import { validateBundle } from "./base-definitions/bundle.js";
9
+ const BASE_VALIDATORS = {
10
+ Patient: validatePatient,
11
+ Observation: validateObservation,
12
+ Encounter: validateEncounter,
13
+ Condition: validateCondition,
14
+ Coverage: validateCoverage,
15
+ Claim: validateClaim,
16
+ ClaimResponse: validateClaimResponse,
17
+ Bundle: validateBundle,
18
+ };
19
+ /**
20
+ * Detects whether an object contains a circular reference.
21
+ */
22
+ export function hasCircularReference(obj, seen = new WeakSet()) {
23
+ if (!obj || typeof obj !== "object")
24
+ return false;
25
+ if (seen.has(obj))
26
+ return true;
27
+ seen.add(obj);
28
+ for (const key of Object.keys(obj)) {
29
+ const val = obj[key];
30
+ if (val && typeof val === "object") {
31
+ if (hasCircularReference(val, seen))
32
+ return true;
33
+ }
34
+ }
35
+ seen.delete(obj);
36
+ return false;
37
+ }
38
+ /**
39
+ * Resolves a property path (e.g. "name", "category[0].coding[0].system") on an object.
40
+ */
41
+ export function getNestedValue(obj, path) {
42
+ if (!obj || typeof obj !== "object")
43
+ return undefined;
44
+ // Normalize array access like a[0].b -> a.0.b
45
+ const normalizedPath = path.replace(/\[(\d+)\]/g, ".$1");
46
+ const parts = normalizedPath.split(".");
47
+ let current = obj;
48
+ for (const part of parts) {
49
+ if (current === undefined || current === null)
50
+ return undefined;
51
+ current = current[part];
52
+ }
53
+ return current;
54
+ }
55
+ /**
56
+ * Validates profile constraints against a resource.
57
+ */
58
+ export function validateProfileConstraints(resource, profile, rootPath) {
59
+ const issues = [];
60
+ if (profile.resourceType && profile.resourceType !== resource.resourceType) {
61
+ issues.push({
62
+ severity: "error",
63
+ path: `${rootPath}.resourceType`,
64
+ code: "profile-mismatch",
65
+ message: `Profile '${profile.name}' applies to '${profile.resourceType}', but resource is '${String(resource.resourceType)}'.`,
66
+ });
67
+ return issues;
68
+ }
69
+ // Check required elements
70
+ if (profile.requiredElements) {
71
+ for (const elem of profile.requiredElements) {
72
+ const val = getNestedValue(resource, elem);
73
+ if (val === undefined || val === null || val === "" || (Array.isArray(val) && val.length === 0)) {
74
+ issues.push({
75
+ severity: "error",
76
+ path: `${rootPath}.${elem}`,
77
+ code: "profile-required-element",
78
+ message: `Profile '${profile.name}' requires element '${elem}' to be present.`,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ // Check cardinality overrides
84
+ if (profile.cardinalityOverrides) {
85
+ for (const [elem, card] of Object.entries(profile.cardinalityOverrides)) {
86
+ const val = getNestedValue(resource, elem);
87
+ if (card.min !== undefined && card.min > 0) {
88
+ if (val === undefined || val === null) {
89
+ issues.push({
90
+ severity: "error",
91
+ path: `${rootPath}.${elem}`,
92
+ code: "profile-cardinality",
93
+ message: `Profile '${profile.name}' requires at least ${card.min} item(s) for '${elem}', but none found.`,
94
+ });
95
+ }
96
+ else if (Array.isArray(val) && val.length < card.min) {
97
+ issues.push({
98
+ severity: "error",
99
+ path: `${rootPath}.${elem}`,
100
+ code: "profile-cardinality",
101
+ message: `Profile '${profile.name}' requires at least ${card.min} item(s) for '${elem}', found ${val.length}.`,
102
+ });
103
+ }
104
+ }
105
+ if (card.max !== undefined && Array.isArray(val) && val.length > card.max) {
106
+ issues.push({
107
+ severity: "error",
108
+ path: `${rootPath}.${elem}`,
109
+ code: "profile-cardinality",
110
+ message: `Profile '${profile.name}' allows at most ${card.max} item(s) for '${elem}', found ${val.length}.`,
111
+ });
112
+ }
113
+ }
114
+ }
115
+ // Check fixed values
116
+ if (profile.fixedValues) {
117
+ for (const [fixedPath, expectedValue] of Object.entries(profile.fixedValues)) {
118
+ const actualValue = getNestedValue(resource, fixedPath);
119
+ if (actualValue !== expectedValue) {
120
+ issues.push({
121
+ severity: "error",
122
+ path: `${rootPath}.${fixedPath}`,
123
+ code: "profile-fixed-value",
124
+ message: `Profile '${profile.name}' requires '${fixedPath}' to equal '${String(expectedValue)}', found '${String(actualValue)}'.`,
125
+ });
126
+ }
127
+ }
128
+ }
129
+ return issues;
130
+ }
131
+ /**
132
+ * Validates a FHIR R4 resource against base structural rules and optional profile constraints.
133
+ *
134
+ * @param resource The FHIR JSON object to validate.
135
+ * @param options Optional validation parameters (e.g. custom or illustrative profile).
136
+ * @returns ValidationResult containing `valid` boolean and list of `issues`.
137
+ */
138
+ export function validate(resource, options) {
139
+ const issues = [];
140
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
141
+ return {
142
+ valid: false,
143
+ issues: [
144
+ {
145
+ severity: "error",
146
+ path: "resource",
147
+ code: "invalid-structure",
148
+ message: `Expected resource to be a JSON object, got ${Array.isArray(resource) ? "array" : typeof resource}.`,
149
+ },
150
+ ],
151
+ };
152
+ }
153
+ if (hasCircularReference(resource)) {
154
+ return {
155
+ valid: false,
156
+ issues: [
157
+ {
158
+ severity: "error",
159
+ path: "resource",
160
+ code: "circular-reference",
161
+ message: "Resource contains an invalid circular reference.",
162
+ },
163
+ ],
164
+ };
165
+ }
166
+ const res = resource;
167
+ if (typeof res.resourceType !== "string" || !res.resourceType.trim()) {
168
+ return {
169
+ valid: false,
170
+ issues: [
171
+ {
172
+ severity: "error",
173
+ path: "resource.resourceType",
174
+ code: "missing-resource-type",
175
+ message: "Missing or invalid required 'resourceType' string property.",
176
+ },
177
+ ],
178
+ };
179
+ }
180
+ const resourceType = res.resourceType.trim();
181
+ const baseValidator = BASE_VALIDATORS[resourceType];
182
+ if (!baseValidator) {
183
+ issues.push({
184
+ severity: "error",
185
+ path: `${resourceType}.resourceType`,
186
+ code: "unsupported-resource-type",
187
+ message: `Resource type '${resourceType}' is not supported by fhir-validator v1. Supported types: ${Object.keys(BASE_VALIDATORS).join(", ")}.`,
188
+ });
189
+ }
190
+ else {
191
+ // 1. Run base structural validation
192
+ issues.push(...baseValidator(res, resourceType));
193
+ if (resourceType === "Bundle" && Array.isArray(res.entry)) {
194
+ res.entry.forEach((entry, index) => {
195
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
196
+ return;
197
+ const nested = entry.resource;
198
+ if (!nested || typeof nested !== "object" || Array.isArray(nested))
199
+ return;
200
+ const nestedResult = validate(nested);
201
+ nestedResult.issues.forEach((issue) => issues.push({ ...issue, path: `Bundle.entry[${index}].resource.${issue.path}` }));
202
+ });
203
+ }
204
+ }
205
+ // 2. Run profile constraints if provided
206
+ if (options?.profile) {
207
+ issues.push(...validateProfileConstraints(res, options.profile, resourceType));
208
+ }
209
+ const hasErrors = issues.some((i) => i.severity === "error");
210
+ return {
211
+ valid: !hasErrors,
212
+ issues,
213
+ };
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peerbits/fhir-validator",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Structural, cardinality, and reference validation for FHIR R4 resources — a fast base-spec validator with a pluggable illustrative-profile mechanism (not a full conformance engine)",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -23,7 +23,9 @@
23
23
  "build": "tsc -p tsconfig.json",
24
24
  "lint": "eslint .",
25
25
  "typecheck": "tsc --noEmit",
26
- "test": "vitest run"
26
+ "test": "vitest run",
27
+ "prepack": "npm run build && npm run typecheck && npm test",
28
+ "prepublishOnly": "npm run lint && npm run prepack"
27
29
  },
28
30
  "repository": {
29
31
  "type": "git",
@@ -41,9 +43,9 @@
41
43
  "@typescript-eslint/parser": "^8.0.0",
42
44
  "eslint": "^9.0.0",
43
45
  "typescript": "^5.5.0",
44
- "vitest": "^2.0.0"
46
+ "vitest": "^4.0.0"
45
47
  },
46
48
  "engines": {
47
- "node": ">=18"
49
+ "node": ">=20"
48
50
  }
49
51
  }