fhirsmith 0.11.1 → 0.12.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.
package/tx/cs/cs-api.md CHANGED
@@ -105,7 +105,7 @@ All are `async`.
105
105
  | `parent(code)` | `null` | The parent concept, for hierarchical code systems. |
106
106
  | `extensions(code)` | `null` | FHIR extensions on the concept, if any. |
107
107
  | `properties(code)` | `[]` | The defined properties for the concept. |
108
- | `incompleteValidationMessage(code)` | `null` | A message explaining why validation may be incomplete (used by SCT). |
108
+ | `incompleteValidationMessage(code)` | `null` | A message explaining why validation may be incomplete. No provider overrides this: SNOMED expressions are checked against the concept model, and the only things it cannot check are ones SNOMED itself publishes no rule for. |
109
109
  | `sameConcept(a, b)` | `false` | Returns `true` if `a` and `b` refer to the same concept (e.g. different expression forms). |
110
110
  | `isDisplay(designation)` | `false` | Called for designations not already marked with a standard display use code; return `true` to treat the designation as a display. |
111
111
  | `subsumesTest(codeA, codeB)` | `'not-subsumed'` | Returns one of `equivalent`, `subsumes`, `subsumed-by`, or `not-subsumed`. |
@@ -148,6 +148,58 @@ class SnomedServices {
148
148
  refSetIndex: this.refSetIndex
149
149
  }, this.isAIndex);
150
150
 
151
+ // The expression services check the MRCM attribute ranges, but those are ECL
152
+ // and the ECL engine lives here, so hand them an evaluator. Returning null
153
+ // means "could not decide", and leaves the value unchecked.
154
+ this.expressionServices.eclConstraintMatcher = (constraint, value) => this.mrcmRangeSatisfied(constraint, value);
155
+ this._mrcmRangeCache = new Map();
156
+ }
157
+
158
+ /**
159
+ * Does this refinement value satisfy an MRCM range constraint?
160
+ *
161
+ * @param {string} constraint - the rangeConstraint ECL, e.g. "<< 182353008 |Side|"
162
+ * @param {SnomedExpression} value - the refinement's value expression
163
+ * @returns {boolean|null} null when the constraint cannot be evaluated
164
+ */
165
+ mrcmRangeSatisfied(constraint, value) {
166
+ let entry = this._mrcmRangeCache.get(constraint);
167
+ if (entry === undefined) {
168
+ entry = null;
169
+ try {
170
+ const ast = new ECLParser(new ECLLexer(constraint).tokenize()).parse();
171
+ const ctxt = this._evalECLNode(ast, null);
172
+ entry = {
173
+ ast: ast,
174
+ wildcard: !!ctxt.eclWildcard,
175
+ members: new Set(this._eclToIndexArray(ctxt))
176
+ };
177
+ } catch (error) {
178
+ // Concrete value ranges (dec/int/str) and anything else the ECL engine
179
+ // does not support: remembered as unusable so it is parsed only once.
180
+ entry = null;
181
+ }
182
+ this._mrcmRangeCache.set(constraint, entry);
183
+ }
184
+ if (!entry) {
185
+ return null;
186
+ }
187
+ if (entry.wildcard) {
188
+ return true;
189
+ }
190
+
191
+ const concepts = (value.concepts || []).filter(c => c.reference != null && c.reference !== NO_REFERENCE);
192
+ const refined = value.hasRefinements() || value.hasRefinementGroups();
193
+ if (!refined) {
194
+ if (concepts.length === 0) {
195
+ return null; // a concrete value, or nothing we can resolve
196
+ }
197
+ // Every focus concept of a plain value must be in the constraint's set.
198
+ return concepts.every(c => entry.members.has(c.reference));
199
+ }
200
+ // A refined value: ask the ECL engine whether the expression satisfies the
201
+ // constraint (it knows that a refinement of X is still << X).
202
+ return this._eclExpressionSatisfies({ expression: value }, entry.ast, null);
151
203
  }
152
204
 
153
205
  close() {
@@ -664,7 +716,7 @@ class SnomedServices {
664
716
  // we actually need the full concept list, otherwise filterSize returns 0
665
717
  // and the iteration yields nothing. Materialise active concepts now.
666
718
  if (forIteration && result.eclWildcard && (!result.descendants || result.descendants.length === 0)) {
667
- result.descendants = this._eclEnumerateActiveConcepts(opContext);
719
+ result.descendants = this._eclEnumerateAllConcepts(opContext);
668
720
  delete result.eclWildcard;
669
721
  // The materialised wildcard form differs from the lazy (eclWildcard) form,
670
722
  // so mark it: the filter cache must not share it across iteration modes.
@@ -677,31 +729,29 @@ class SnomedServices {
677
729
  };
678
730
 
679
731
  /**
680
- * Return every active concept's index. Used to materialise wildcard results
681
- * when the filter needs to be iterated over (e.g. $expand).
732
+ * Return every concept's index. Used to materialise wildcard results when the
733
+ * filter needs to be iterated over (e.g. $expand).
734
+ *
735
+ * `*` means every concept the server knows, so this deliberately walks the
736
+ * concept table rather than descending from 138875005 |SNOMED CT Concept|.
737
+ * Two kinds of concept are in the table but not reachable from that root:
738
+ * inactive concepts (their is-a relationships are inactive too), and the
739
+ * foundation-metadata concepts that a subset carries without their is-a
740
+ * relationships - module concepts above all. Both belong in `*`, and both are
741
+ * counted by countAllCodes, so descending from the root made $expand of a
742
+ * `constraint = *` filter disagree with $expand of the whole code system.
743
+ *
744
+ * Inactive concepts are not filtered out here: activeOnly is applied by the
745
+ * expansion itself (see ValueSetExpander.processCodes), which is what lets one
746
+ * enumeration serve both activeOnly=true and activeOnly=false.
682
747
  * @returns {number[]}
683
748
  */
684
- _eclEnumerateActiveConcepts = function (opContext) {
685
- // The wildcard `*` enumerates the SNOMED CT concept hierarchy: the root
686
- // 138875005 |SNOMED CT Concept| and its descendants. This excludes the
687
- // foundation-metadata concepts that appear in a subset's concept table
688
- // (referenced by the RF2 files as module / type / acceptability ... ids)
689
- // without their is-a relationships, so they are not reachable from the root
690
- // and are not returned by `*` (matching Snowstorm). On a full edition every
691
- // real concept is under the root, so nothing is lost.
692
- const rootRes = this.concepts.findConcept(138875005n);
693
- if (rootRes && rootRes.found) {
694
- return this.filterIsA(138875005n, true).descendants;
695
- }
696
- // Fallback (no recognisable root): every active concept.
749
+ _eclEnumerateAllConcepts = function (opContext) {
697
750
  const all = [];
698
751
  const n = this.concepts.count();
699
752
  for (let i = 0; i < n; i++) {
700
- if (opContext) opContext.deadCheck('ecl:enumerateActiveConcepts');
701
- const concept = this.concepts.getConceptByCount(i);
702
- if ((concept.flags & 0x0F) === 0) {
703
- all.push(concept.index);
704
- }
753
+ if (opContext) opContext.deadCheck('ecl:enumerateAllConcepts');
754
+ all.push(this.concepts.getConceptByCount(i).index);
705
755
  }
706
756
  return all;
707
757
  };
@@ -918,7 +968,7 @@ class SnomedServices {
918
968
  };
919
969
 
920
970
  /**
921
- * Wildcard — all active concepts. The eclWildcard flag tells filterCheck /
971
+ * Wildcard — every concept, active or not. The eclWildcard flag tells filterCheck /
922
972
  * filterLocate to accept every active concept without enumeration.
923
973
  * @returns {SnomedFilterContext}
924
974
  */
@@ -1312,15 +1362,15 @@ class SnomedServices {
1312
1362
 
1313
1363
  /**
1314
1364
  * Like _eclToIndexArray, but if the context is a bare wildcard (no
1315
- * descendants populated) it materialises the full active-concept list
1316
- * via _eclEnumerateActiveConcepts. Used by dotted/refined evaluation,
1365
+ * descendants populated) it materialises the full concept list
1366
+ * via _eclEnumerateAllConcepts. Used by dotted/refined evaluation,
1317
1367
  * which need an explicit concept set to iterate over.
1318
1368
  * @param {SnomedFilterContext} ctx
1319
1369
  * @returns {number[]}
1320
1370
  */
1321
1371
  _eclResolveSet = function (ctx, opContext) {
1322
1372
  if (ctx.eclWildcard && (!ctx.descendants || ctx.descendants.length === 0)) {
1323
- return this._eclEnumerateActiveConcepts(opContext);
1373
+ return this._eclEnumerateAllConcepts(opContext);
1324
1374
  }
1325
1375
  return this._eclToIndexArray(ctx);
1326
1376
  };
@@ -1369,9 +1419,9 @@ class SnomedServices {
1369
1419
  const rightSet = new Set(this._eclToIndexArray(right));
1370
1420
 
1371
1421
  if (left.eclWildcard) {
1372
- // Wildcard minus the right set: use the shared active-concept enumeration
1373
- // (the root hierarchy) so `*` behaves consistently everywhere.
1374
- result.descendants = this._eclEnumerateActiveConcepts(opContext)
1422
+ // Wildcard minus the right set: use the shared enumeration so `*` behaves
1423
+ // consistently everywhere.
1424
+ result.descendants = this._eclEnumerateAllConcepts(opContext)
1375
1425
  .filter(idx => !rightSet.has(idx));
1376
1426
  return result;
1377
1427
  }
@@ -1605,13 +1655,25 @@ class SnomedProvider extends BaseCSServices {
1605
1655
  const K = this.sct._displayConstants();
1606
1656
 
1607
1657
  if (significantOnly) {
1608
- // $expand: emit the "significant" designations only — the FSN plus
1609
- // the preferred synonym (US English), active descriptions only.
1610
- // The preferred synonym is also the display, so it is added as the
1611
- // display designation (which the expand worker selects for
1612
- // `display` and skips from the emitted list) and additionally as a
1613
- // Synonym designation so it still appears in the designation array.
1614
- let fsn = null, prefSyn = null;
1658
+ // $expand: emit the "significant" designations only — the FSN and
1659
+ // the preferred synonym, active descriptions only. Acceptable
1660
+ // synonyms are the bulk of a SNOMED concept's descriptions and are
1661
+ // not needed to render an expansion, so they are dropped.
1662
+ //
1663
+ // One per LANGUAGE, though, not one overall: the display for a
1664
+ // request is chosen downstream by
1665
+ // Designations.preferredDesignation(displayLanguage), so a concept
1666
+ // that emits only its English terms has nothing for a
1667
+ // displayLanguage=de request to match and silently falls back to
1668
+ // English. Keeping the preferred synonym in every language the
1669
+ // concept has is what makes localized expansions work at all
1670
+ // (e.g. the Swiss edition, whose German terms are preferred in the
1671
+ // Swiss German language refset 2041000195100).
1672
+ //
1673
+ // The edition-default display is also added as the display
1674
+ // designation (which the expand worker selects for `display` when
1675
+ // no language was asked for, and skips from the emitted list).
1676
+ const fsns = new Map(), prefSyns = new Map();
1615
1677
  for (const descIndex of descriptionIndices) {
1616
1678
  const description = this.sct.descriptions.getDescription(descIndex);
1617
1679
  if (!description.active) continue;
@@ -1620,15 +1682,20 @@ class SnomedProvider extends BaseCSServices {
1620
1682
  const kind = this.sct.concepts.getConcept(description.kind);
1621
1683
  const use = { system: 'http://snomed.info/sct', code: String(kind.identity), display: this.sct.getDisplayName(description.kind) };
1622
1684
  if (description.kind === K.fsn) {
1623
- if (!fsn) fsn = { langCode, use, term };
1685
+ if (!fsns.has(langCode)) fsns.set(langCode, { langCode, use, term });
1624
1686
  } else if (this.sct._synonymIsPreferred(description)) {
1625
- if (!prefSyn) prefSyn = { langCode, use, term };
1687
+ if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
1626
1688
  }
1627
1689
  }
1628
1690
  const display = this.sct.getDisplayName(ctxt.getReference());
1629
1691
  if (display) displays.addDesignation(true, 'active', 'en-US', null, display);
1630
- if (fsn) displays.addDesignation(false, 'active', fsn.langCode, fsn.use, fsn.term);
1631
- if (prefSyn) displays.addDesignation(false, 'active', prefSyn.langCode, prefSyn.use, prefSyn.term);
1692
+ // FSNs before synonyms, matching the designation order the published
1693
+ // tx-ecosystem expectations were written against (sct/expand-inactive).
1694
+ // Which of the two is chosen as the display is settled by
1695
+ // Designations.preferredDesignation(), which ranks the SNOMED synonym
1696
+ // above the FSN - not by the order they are emitted in.
1697
+ for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1698
+ for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1632
1699
  } else {
1633
1700
  // $lookup: emit every description (preferred synonym first so the
1634
1701
  // display resolves correctly; order is not otherwise significant).
@@ -1717,19 +1784,6 @@ class SnomedProvider extends BaseCSServices {
1717
1784
  }
1718
1785
  }
1719
1786
 
1720
- async incompleteValidationMessage(context) {
1721
-
1722
- const ctxt = await this.#ensureContext(context);
1723
-
1724
- if (!ctxt) return null;
1725
-
1726
- if (ctxt.isComplex()) {
1727
- return "The expression is grammatically correct and the concepts are valid, but the expression has not been checked against the SNOMED CT concept model (MRCM)";
1728
- } else {
1729
- return null;
1730
- }
1731
- }
1732
-
1733
1787
  async locateIsA(code, parent, disallowParent = false) {
1734
1788
 
1735
1789
 
@@ -1864,25 +1918,37 @@ class SnomedProvider extends BaseCSServices {
1864
1918
  if (ctxt instanceof SnomedExpressionContext) {
1865
1919
  // ignore concepts for now, but list refinements and refinement groups
1866
1920
  for (const refinement of ctxt.expression.refinements) {
1867
- const codeA = refinement.name.code;
1868
- const codeB = refinement.value.describe();
1869
- const description = await this.display(codeB);
1870
- let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
1871
- p.part.push({name: 'code-display', valueString: await this.display(codeA)});
1921
+ await this._addRefinementProperty(params, refinement);
1872
1922
  }
1873
1923
  for (const refinementGroup of ctxt.expression.refinementGroups) {
1874
1924
  for (const refinement of refinementGroup.refinements) {
1875
- const codeA = refinement.name.code;
1876
- const codeB = refinement.value.describe();
1877
- const description = await this.display(codeB);
1878
- let p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
1879
- p.part.push({name: 'code-display', valueString: await this.display(codeA)});
1925
+ await this._addRefinementProperty(params, refinement);
1880
1926
  }
1881
1927
  }
1882
1928
  }
1883
1929
  }
1884
1930
  }
1885
1931
 
1932
+ /**
1933
+ * Report one refinement of a post-coordinated expression as a lookup property.
1934
+ *
1935
+ * The value is handled as an expression, not as a string: SnomedExpression's
1936
+ * describe() is a human-readable rendering that joins concepts and refinements
1937
+ * with commas ("85562004,272741003=24028007"), so round-tripping it through
1938
+ * display() parsed it back as SCG and failed on any refined value.
1939
+ *
1940
+ * @param {Object} params - the Parameters being built
1941
+ * @param {SnomedRefinement} refinement
1942
+ */
1943
+ async _addRefinementProperty(params, refinement) {
1944
+ const codeA = refinement.name.code;
1945
+ const value = SnomedExpressionContext.fromExpression('', refinement.value);
1946
+ const codeB = await this.code(value);
1947
+ const description = await this.display(value);
1948
+ const p = this._addCodeProperty(params, 'property', codeA, codeB, null, description);
1949
+ p.part.push({name: 'code-display', valueString: await this.display(codeA)});
1950
+ }
1951
+
1886
1952
  // Filter support
1887
1953
  async doesFilter(prop, op, value) {
1888
1954
  if (prop === 'concept') {
@@ -2114,7 +2180,7 @@ class SnomedProvider extends BaseCSServices {
2114
2180
  // A wildcard (`*`) set is lazy - it carries the eclWildcard flag rather than a
2115
2181
  // materialised descendant list - so count the active concepts it denotes.
2116
2182
  if (set.eclWildcard && (!set.descendants || set.descendants.length === 0)) {
2117
- return this.sct._eclEnumerateActiveConcepts(this.opContext).length;
2183
+ return this.sct._eclEnumerateAllConcepts(this.opContext).length;
2118
2184
  }
2119
2185
  if (set.matches && set.matches.length > 0) {
2120
2186
  return set.matches.length;
@@ -2166,7 +2232,7 @@ class SnomedProvider extends BaseCSServices {
2166
2232
  const reference = ctxt.getReference();
2167
2233
 
2168
2234
  if (set.eclWildcard) {
2169
- return this.sct.isActive(reference) ? ctxt : null;
2235
+ return ctxt; // `*` is every concept; inactive ones included
2170
2236
  }
2171
2237
 
2172
2238
  // A post-coordinated expression validated against an ECL value set: test the
@@ -2249,7 +2315,7 @@ class SnomedProvider extends BaseCSServices {
2249
2315
  return this.#descendantsHas(set, reference);
2250
2316
  }
2251
2317
  if (set.eclWildcard) {
2252
- return this.sct.isActive(reference);
2318
+ return true; // `*` is every concept; inactive ones included
2253
2319
  }
2254
2320
  return false;
2255
2321
  }
@@ -173,6 +173,45 @@ class ConceptMap extends CanonicalResource {
173
173
  return result;
174
174
  }
175
175
 
176
+ /**
177
+ * The canonical references this map defers to. Only group.unmapped.otherMap creates
178
+ * such a dependency: a map that names another map cannot be resolved without it, so
179
+ * it has to be consulted first.
180
+ * @returns {string[]} canonical references, as written (may or may not be versioned)
181
+ */
182
+ listDependencies() {
183
+ const result = [];
184
+ for (const g of this.jsonObj.group || []) {
185
+ if (g.unmapped && g.unmapped.mode === 'other-map' && g.unmapped.otherMap) {
186
+ if (!result.includes(g.unmapped.otherMap)) {
187
+ result.push(g.unmapped.otherMap);
188
+ }
189
+ }
190
+ }
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * The groups that would have been consulted for this coding, whether or not any of
196
+ * their elements matched. group.unmapped only applies to a group that was in scope
197
+ * for the translation, so the caller needs the same source/target test that
198
+ * listTranslations applies - without the per-element filter.
199
+ * @returns {Object[]} the matching group objects
200
+ */
201
+ listGroupsInScope(coding, targetScope, targetSystem) {
202
+ const result = [];
203
+ const vurl = VersionUtilities.vurl(coding.system, coding.version);
204
+ const all = this.canonicalMatches(targetScope, this.targetScope);
205
+ for (const g of this.jsonObj.group || []) {
206
+ const sourceOk = this.canonicalMatches(vurl, g.source);
207
+ const targetOk = !targetSystem || this.canonicalMatches(targetSystem, g.target);
208
+ if (all || (sourceOk && targetOk)) {
209
+ result.push(g);
210
+ }
211
+ }
212
+ return result;
213
+ }
214
+
176
215
  listTranslationsReverse(coding, targetScope, sourceSystem) {
177
216
  let result = [];
178
217
  let vurl = VersionUtilities.vurl(coding.system, coding.version);
@@ -610,8 +610,15 @@ class Designations {
610
610
 
611
611
  for (const activeOnly of [true, false]) {
612
612
  for (const matchType of matchTypes) {
613
+ // _isPreferred before isDisplay, matching the no-language branch above.
614
+ // The two sets differ only where a provider's own isDisplay() accepts a
615
+ // designation that isn't a preferred term - today that is the SNOMED FSN,
616
+ // which isDisplay() accepts and _isPreferred() deliberately does not. Testing
617
+ // isDisplay first made a request for a language whose concept has both an FSN
618
+ // and a preferred synonym display the "(disorder)"-suffixed FSN, purely
619
+ // because the FSN was emitted first.
613
620
  for (const cd of this.designations) {
614
- if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd) && (!activeOnly || cd.isActive())) {
621
+ if (this._langMatches(lang, cd.language, matchType) && this._isPreferred(cd) && (!activeOnly || cd.isActive())) {
615
622
  if (supplements && cd.source) {
616
623
  supplements.add(cd.source);
617
624
  }
@@ -619,7 +626,7 @@ class Designations {
619
626
  }
620
627
  }
621
628
  for (const cd of this.designations) {
622
- if (this._langMatches(lang, cd.language, matchType) && this._isPreferred(cd)&& (!activeOnly || cd.isActive())) {
629
+ if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd) && (!activeOnly || cd.isActive())) {
623
630
  if (supplements && cd.source) {
624
631
  supplements.add(cd.source);
625
632
  }
@@ -0,0 +1,139 @@
1
+ //
2
+ // Checking what a code system provider adds to a $lookup response
3
+ //
4
+ // extendLookup() hands the provider the response parameter list and lets it
5
+ // push whatever it likes. Most providers push values they built themselves out
6
+ // of curated content, where the shape is known good. Some pass data through
7
+ // from a remote API, where a value can be any JSON at all - an object, an
8
+ // array, a null - and a structure like that in a FHIR primitive produces a
9
+ // response that is not valid FHIR.
10
+ //
11
+ // That's a bug in the provider, not something the client did, so it fails the
12
+ // operation (500/exception) rather than shipping a malformed response and
13
+ // leaving someone downstream to work out where it came from.
14
+ //
15
+
16
+ // value[x] elements carried by a JSON string
17
+ const STRING_VALUES = new Set([
18
+ 'valueString', 'valueCode', 'valueUri', 'valueUrl', 'valueCanonical',
19
+ 'valueId', 'valueOid', 'valueUuid', 'valueMarkdown', 'valueBase64Binary',
20
+ 'valueDate', 'valueDateTime', 'valueTime', 'valueInstant'
21
+ ]);
22
+
23
+ // value[x] elements carried by a JSON integer
24
+ const INTEGER_VALUES = new Set(['valueInteger', 'valuePositiveInt', 'valueUnsignedInt']);
25
+
26
+ // The one complex type a lookup property is allowed to carry. Its own members
27
+ // are all primitives (system, version, code, display, userSelected), so an
28
+ // object or array inside one is just as wrong as it would be at the top.
29
+ const CODING_VALUE = 'valueCoding';
30
+
31
+ function describe(value) {
32
+ if (value === null) {
33
+ return 'null';
34
+ }
35
+ if (Array.isArray(value)) {
36
+ return 'an array';
37
+ }
38
+ return 'a ' + typeof value;
39
+ }
40
+
41
+ function fail(source, path, message) {
42
+ throw new Error(`Code system provider ${source} produced an invalid $lookup response at ${path}: ${message}`);
43
+ }
44
+
45
+ function checkCoding(value, source, path) {
46
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
47
+ fail(source, path, `valueCoding must be an object, not ${describe(value)}`);
48
+ }
49
+ for (const [key, member] of Object.entries(value)) {
50
+ if (member === null || member === undefined) {
51
+ continue;
52
+ }
53
+ const type = typeof member;
54
+ if (type !== 'string' && type !== 'number' && type !== 'boolean') {
55
+ fail(source, path, `valueCoding.${key} must be a primitive, not ${describe(member)}`);
56
+ }
57
+ }
58
+ }
59
+
60
+ function checkValue(key, value, source, path) {
61
+ if (STRING_VALUES.has(key)) {
62
+ if (typeof value !== 'string') {
63
+ fail(source, path, `${key} must be a string, not ${describe(value)}`);
64
+ }
65
+ return;
66
+ }
67
+ if (INTEGER_VALUES.has(key)) {
68
+ if (!Number.isInteger(value)) {
69
+ fail(source, path, `${key} must be an integer, not ${describe(value)}`);
70
+ }
71
+ return;
72
+ }
73
+ if (key === 'valueDecimal') {
74
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
75
+ fail(source, path, `${key} must be a finite number, not ${describe(value)}`);
76
+ }
77
+ return;
78
+ }
79
+ if (key === 'valueBoolean') {
80
+ if (typeof value !== 'boolean') {
81
+ fail(source, path, `${key} must be a boolean, not ${describe(value)}`);
82
+ }
83
+ return;
84
+ }
85
+ if (key === CODING_VALUE) {
86
+ checkCoding(value, source, path);
87
+ return;
88
+ }
89
+ fail(source, path, `${key} is not a value type a lookup property may carry`);
90
+ }
91
+
92
+ function checkParameter(param, source, path) {
93
+ if (param === null || typeof param !== 'object' || Array.isArray(param)) {
94
+ fail(source, path, `a parameter must be an object, not ${describe(param)}`);
95
+ }
96
+ if (typeof param.name !== 'string' || param.name === '') {
97
+ fail(source, path, `a parameter must have a name, not ${describe(param.name)}`);
98
+ }
99
+
100
+ const name = param.name;
101
+ const valueKeys = Object.keys(param).filter(key => key.startsWith('value'));
102
+ if (valueKeys.length > 1) {
103
+ fail(source, `${path}.${name}`, `a parameter may carry one value, not ${valueKeys.length} (${valueKeys.join(', ')})`);
104
+ }
105
+ for (const key of valueKeys) {
106
+ checkValue(key, param[key], source, `${path}.${name}`);
107
+ }
108
+
109
+ if (param.part !== undefined) {
110
+ if (!Array.isArray(param.part)) {
111
+ fail(source, `${path}.${name}`, `part must be an array, not ${describe(param.part)}`);
112
+ }
113
+ for (const part of param.part) {
114
+ checkParameter(part, source, `${path}.${name}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Check the parameters a provider added to a lookup response.
121
+ *
122
+ * @param {Array} params - the whole response parameter list
123
+ * @param {number} fromIndex - where the provider's own additions start, so
124
+ * that only what extendLookup() added is checked
125
+ * @param {string} source - the code system, for the error message
126
+ * @throws {Error} if the provider produced something that isn't valid FHIR
127
+ */
128
+ function checkAddedParameters(params, fromIndex, source) {
129
+ if (!Array.isArray(params)) {
130
+ return;
131
+ }
132
+ for (let i = fromIndex; i < params.length; i++) {
133
+ checkParameter(params[i], source || 'unknown', 'parameter');
134
+ }
135
+ }
136
+
137
+ module.exports = {
138
+ checkAddedParameters
139
+ };
package/tx/library.js CHANGED
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const yaml = require('yaml'); // npm install yaml
5
5
  const { PackageManager, PackageContentLoader } = require('../library/package-manager');
6
- const { FolderContentLoader } = require('../library/folder-content-loader');
6
+ const { FolderContentLoader, FileContentLoader } = require('../library/folder-content-loader');
7
7
  const { CodeSystem } = require("./library/codesystem");
8
8
  const {CountryCodeFactoryProvider} = require("./cs/cs-country");
9
9
  const {Iso4217FactoryProvider} = require("./cs/cs-currency");
@@ -301,6 +301,10 @@ class Library {
301
301
  await this.loadFolder(details, isDefault, mode);
302
302
  break;
303
303
 
304
+ case 'file':
305
+ await this.loadFile(details, isDefault, mode);
306
+ break;
307
+
304
308
  default:
305
309
  throw new Error(`Unknown source type: ${type}`);
306
310
  }
@@ -679,19 +683,68 @@ class Library {
679
683
  return;
680
684
  }
681
685
 
682
- const folderPath = path.isAbsolute(details)
683
- ? details
684
- : path.resolve(path.join(__dirname, '..', details));
686
+ const folderPath = this.#resolveLocalSourcePath(details);
687
+ const cacheSubdir = await this.#prepareLocalSourceCache(folderPath);
688
+ await this.#loadLocalSource(new FolderContentLoader(folderPath, cacheSubdir));
689
+ }
690
+
691
+ /**
692
+ * Loads a single nominated JSON file holding a CodeSystem, ValueSet or ConceptMap
693
+ * (source syntax: `file:<path>`). The resource is routed to the appropriate provider
694
+ * based on its resourceType.
695
+ *
696
+ * Unlike a folder source, the file must exist and must be one of the three terminology
697
+ * resource types - anything else is an error, since the config named it explicitly.
698
+ *
699
+ * Relative paths are resolved against the project root (same convention as loadUcum etc.).
700
+ *
701
+ * @param {string} details - The file to load
702
+ * @param {boolean} isDefault - Unused; file sources don't register factories
703
+ * @param {string} mode - One of "fetch", "cs", "npm"
704
+ */
705
+ // eslint-disable-next-line no-unused-vars
706
+ async loadFile(details, isDefault, mode) {
707
+ if (mode === "fetch" || mode === "cs") {
708
+ return;
709
+ }
710
+
711
+ const filePath = this.#resolveLocalSourcePath(details);
712
+ const cacheSubdir = await this.#prepareLocalSourceCache(filePath);
713
+ await this.#loadLocalSource(new FileContentLoader(filePath, cacheSubdir));
714
+ }
715
+
716
+ /**
717
+ * Relative paths in folder:/file: sources are resolved against the project root.
718
+ * @private
719
+ */
720
+ #resolveLocalSourcePath(details) {
721
+ const text = String(details || '').trim();
722
+ if (!text) {
723
+ throw new Error('A local source requires a path, e.g. file:tx/data/my-valueset.json');
724
+ }
725
+ return path.isAbsolute(text) ? text : path.resolve(path.join(__dirname, '..', text));
726
+ }
685
727
 
686
- // Park the Package*Provider SQLite caches under the terminology cache rather
687
- // than polluting the user's source folder, and wipe between runs so edits to
688
- // the source folder are reliably picked up.
689
- const hash = crypto.createHash('sha1').update(folderPath).digest('hex').substring(0, 16);
728
+ /**
729
+ * Parks the Package*Provider SQLite caches under the terminology cache rather
730
+ * than polluting the user's source folder, and wipes between runs so edits to
731
+ * the source are reliably picked up.
732
+ * @private
733
+ */
734
+ async #prepareLocalSourceCache(sourcePath) {
735
+ const hash = crypto.createHash('sha1').update(sourcePath).digest('hex').substring(0, 16);
690
736
  const cacheSubdir = path.join(this.cacheFolder, 'folder-source-' + hash);
691
737
  await fs.rm(cacheSubdir, { recursive: true, force: true });
692
738
  await fs.mkdir(cacheSubdir, { recursive: true });
739
+ return cacheSubdir;
740
+ }
693
741
 
694
- const contentLoader = new FolderContentLoader(folderPath, cacheSubdir);
742
+ /**
743
+ * Shared loader for folder- and file-sourced content: registers the loader's
744
+ * CodeSystems, ValueSets and ConceptMaps with this library.
745
+ * @private
746
+ */
747
+ async #loadLocalSource(contentLoader) {
695
748
  await contentLoader.initialize();
696
749
 
697
750
  this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());