fhirsmith 0.11.1 → 0.11.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.11.2 - 2026-08-12
9
+
10
+ ### Fixed
11
+
12
+ - Update snomed test content
13
+
14
+ ### Fixed
15
+
16
+ - Fix snomed designation handling for languages
17
+ - Fix for SSEF protection in the validator-
18
+
19
+ ### Tx Conformance Statement
20
+
21
+ FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.3, runner v6.10.1)
22
+
8
23
  ## [0.11.1] - 2026-07-30
9
24
 
10
25
  ### Added
@@ -2,6 +2,8 @@ const fs = require('fs').promises;
2
2
  const path = require('path');
3
3
  const { PackageContentLoader } = require('./package-manager');
4
4
 
5
+ const TERMINOLOGY_TYPES = ['CodeSystem', 'ValueSet', 'ConceptMap'];
6
+
5
7
  /**
6
8
  * A content loader that scans a folder for JSON FHIR terminology resources
7
9
  * (CodeSystem, ValueSet, ConceptMap). It synthesizes the package layout that
@@ -26,14 +28,27 @@ class FolderContentLoader extends PackageContentLoader {
26
28
  }
27
29
 
28
30
  /**
29
- * Replaces the standard initialize: there is no package.json or .index.json
30
- * to read, so we synthesize an index from the JSON files in the folder.
31
+ * The synthesized package name reported by id()/pid().
32
+ * @returns {string}
31
33
  */
32
- async initialize() {
33
- if (this.loaded) {
34
- return;
35
- }
34
+ _packageName() {
35
+ return 'folder.' + path.basename(this.sourceFolder);
36
+ }
37
+
38
+ /**
39
+ * Whether a file that can't be parsed, or that isn't a terminology resource,
40
+ * should be quietly skipped. True for folder scans (the folder may hold all
41
+ * sorts of things); false when a specific file was nominated.
42
+ * @returns {boolean}
43
+ */
44
+ get _skipInvalidFiles() {
45
+ return true;
46
+ }
36
47
 
48
+ /**
49
+ * Check the source exists and is the right kind of thing.
50
+ */
51
+ async _validateSource() {
37
52
  try {
38
53
  const stat = await fs.stat(this.sourceFolder);
39
54
  if (!stat.isDirectory()) {
@@ -45,36 +60,63 @@ class FolderContentLoader extends PackageContentLoader {
45
60
  }
46
61
  throw err;
47
62
  }
63
+ }
64
+
65
+ /**
66
+ * The file names (relative to sourceFolder) to consider.
67
+ * @returns {Promise<Array<string>>}
68
+ */
69
+ async _candidateFiles() {
70
+ const names = [];
71
+ const entries = await fs.readdir(this.sourceFolder, { withFileTypes: true });
72
+ for (const entry of entries) {
73
+ if (!entry.isFile()) continue;
74
+ if (!entry.name.toLowerCase().endsWith('.json')) continue;
75
+ names.push(entry.name);
76
+ }
77
+ return names;
78
+ }
79
+
80
+ /**
81
+ * Replaces the standard initialize: there is no package.json or .index.json
82
+ * to read, so we synthesize an index from the JSON files in the source.
83
+ */
84
+ async initialize() {
85
+ if (this.loaded) {
86
+ return;
87
+ }
88
+
89
+ await this._validateSource();
48
90
 
49
91
  // Synthesize package metadata so id()/version()/pid() return something usable.
50
92
  this.package = {
51
- name: 'folder.' + path.basename(this.sourceFolder),
93
+ name: this._packageName(),
52
94
  version: '0.0.0',
53
95
  fhirVersions: ['5.0.0']
54
96
  };
55
97
 
56
98
  const files = [];
57
- const entries = await fs.readdir(this.sourceFolder, { withFileTypes: true });
58
- for (const entry of entries) {
59
- if (!entry.isFile()) continue;
60
- if (!entry.name.toLowerCase().endsWith('.json')) continue;
61
-
62
- const fullPath = path.join(this.sourceFolder, entry.name);
99
+ for (const name of await this._candidateFiles()) {
100
+ const fullPath = path.join(this.sourceFolder, name);
63
101
  let json;
64
102
  try {
65
103
  const content = await fs.readFile(fullPath, 'utf8');
66
104
  json = JSON.parse(content);
67
- } catch {
68
- // Skip unreadable / non-JSON files rather than failing the whole load.
69
- continue;
105
+ } catch (e) {
106
+ // Skip unreadable / non-JSON files rather than failing the whole load,
107
+ // unless this exact file was the nominated source.
108
+ if (this._skipInvalidFiles) continue;
109
+ throw new Error(`Unable to read FHIR resource from ${fullPath}: ${e.message}`);
70
110
  }
71
111
 
72
- if (!json || typeof json !== 'object') continue;
73
- const rt = json.resourceType;
74
- if (rt !== 'CodeSystem' && rt !== 'ValueSet' && rt !== 'ConceptMap') continue;
112
+ const rt = json && typeof json === 'object' ? json.resourceType : null;
113
+ if (!TERMINOLOGY_TYPES.includes(rt)) {
114
+ if (this._skipInvalidFiles) continue;
115
+ throw new Error(`${fullPath} is not a CodeSystem, ValueSet or ConceptMap (resourceType = ${rt || 'none'})`);
116
+ }
75
117
 
76
118
  files.push({
77
- filename: entry.name,
119
+ filename: name,
78
120
  resourceType: rt,
79
121
  id: json.id,
80
122
  url: json.url,
@@ -88,4 +130,52 @@ class FolderContentLoader extends PackageContentLoader {
88
130
  }
89
131
  }
90
132
 
91
- module.exports = { FolderContentLoader };
133
+ /**
134
+ * A content loader for a single nominated JSON file holding one CodeSystem,
135
+ * ValueSet or ConceptMap. Everything else works exactly as for a folder source:
136
+ * the file's own directory is the read-only content root, and the provider
137
+ * caches live in the supplied cacheFolder.
138
+ *
139
+ * Unlike a folder scan, a bad file is an error rather than something to skip -
140
+ * the config named this file explicitly, so failing silently would just hide
141
+ * a typo.
142
+ */
143
+ class FileContentLoader extends FolderContentLoader {
144
+ /**
145
+ * @param {string} filePath - The JSON file to load
146
+ * @param {string} cacheFolder - Folder where Package*Provider DBs may live
147
+ */
148
+ constructor(filePath, cacheFolder) {
149
+ super(path.dirname(filePath), cacheFolder);
150
+ this.filePath = filePath;
151
+ this.fileName = path.basename(filePath);
152
+ }
153
+
154
+ _packageName() {
155
+ return 'file.' + this.fileName.replace(/\.json$/i, '');
156
+ }
157
+
158
+ get _skipInvalidFiles() {
159
+ return false;
160
+ }
161
+
162
+ async _validateSource() {
163
+ try {
164
+ const stat = await fs.stat(this.filePath);
165
+ if (!stat.isFile()) {
166
+ throw new Error(`File source path is not a file: ${this.filePath}`);
167
+ }
168
+ } catch (err) {
169
+ if (err.code === 'ENOENT') {
170
+ throw new Error(`File source path does not exist: ${this.filePath}`);
171
+ }
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ async _candidateFiles() {
177
+ return [this.fileName];
178
+ }
179
+ }
180
+
181
+ module.exports = { FolderContentLoader, FileContentLoader };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.11.1",
4
- "txVersion": "1.9.1",
3
+ "version": "0.11.2",
4
+ "txVersion": "1.9.3",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
7
7
  "engines": {
@@ -46,7 +46,7 @@
46
46
  "express-rate-limit": "^7.4.1",
47
47
  "express-session": "^1.19.0",
48
48
  "fast-xml-parser": "^5.3.4",
49
- "fhir-validator-wrapper": "1.2.2",
49
+ "fhir-validator-wrapper": "1.3.0",
50
50
  "fhirpath": "^4.8.3",
51
51
  "fs-extra": "^11.3.3",
52
52
  "ini": "^6.0.0",
package/tx/cs/cs-api.js CHANGED
@@ -404,9 +404,15 @@ class CodeSystemProvider {
404
404
  * @param {boolean} [significantOnly=false] - when true, emit only the
405
405
  * designations that are significant for a value set expansion (the display,
406
406
  * and - for code systems that distinguish them - the fully specified name
407
- * and the preferred term), rather than every designation in every language.
408
- * The $expand worker passes true; $lookup uses the default (false) to return
409
- * the complete set. Providers that do not distinguish significant
407
+ * and the preferred term), rather than every designation. This narrows which
408
+ * designations are emitted, NOT which languages: the display for a request is
409
+ * chosen downstream by preferredDesignation(displayLanguage), so a provider
410
+ * that emits significant designations for one language only makes localized
411
+ * expansions fall back to that language. Emit the significant designations
412
+ * for every language the concept has.
413
+ * The $expand worker passes true when the client did not ask for
414
+ * designations; $lookup and includeDesignations=true use the default (false)
415
+ * to return the complete set. Providers that do not distinguish significant
410
416
  * designations may ignore this parameter and return all designations either
411
417
  * way.
412
418
  * @returns {Designation[]} whatever designations exist (in all languages)
@@ -1605,13 +1605,25 @@ class SnomedProvider extends BaseCSServices {
1605
1605
  const K = this.sct._displayConstants();
1606
1606
 
1607
1607
  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;
1608
+ // $expand: emit the "significant" designations only — the FSN and
1609
+ // the preferred synonym, active descriptions only. Acceptable
1610
+ // synonyms are the bulk of a SNOMED concept's descriptions and are
1611
+ // not needed to render an expansion, so they are dropped.
1612
+ //
1613
+ // One per LANGUAGE, though, not one overall: the display for a
1614
+ // request is chosen downstream by
1615
+ // Designations.preferredDesignation(displayLanguage), so a concept
1616
+ // that emits only its English terms has nothing for a
1617
+ // displayLanguage=de request to match and silently falls back to
1618
+ // English. Keeping the preferred synonym in every language the
1619
+ // concept has is what makes localized expansions work at all
1620
+ // (e.g. the Swiss edition, whose German terms are preferred in the
1621
+ // Swiss German language refset 2041000195100).
1622
+ //
1623
+ // The edition-default display is also added as the display
1624
+ // designation (which the expand worker selects for `display` when
1625
+ // no language was asked for, and skips from the emitted list).
1626
+ const fsns = new Map(), prefSyns = new Map();
1615
1627
  for (const descIndex of descriptionIndices) {
1616
1628
  const description = this.sct.descriptions.getDescription(descIndex);
1617
1629
  if (!description.active) continue;
@@ -1620,15 +1632,20 @@ class SnomedProvider extends BaseCSServices {
1620
1632
  const kind = this.sct.concepts.getConcept(description.kind);
1621
1633
  const use = { system: 'http://snomed.info/sct', code: String(kind.identity), display: this.sct.getDisplayName(description.kind) };
1622
1634
  if (description.kind === K.fsn) {
1623
- if (!fsn) fsn = { langCode, use, term };
1635
+ if (!fsns.has(langCode)) fsns.set(langCode, { langCode, use, term });
1624
1636
  } else if (this.sct._synonymIsPreferred(description)) {
1625
- if (!prefSyn) prefSyn = { langCode, use, term };
1637
+ if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
1626
1638
  }
1627
1639
  }
1628
1640
  const display = this.sct.getDisplayName(ctxt.getReference());
1629
1641
  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);
1642
+ // FSNs before synonyms, matching the designation order the published
1643
+ // tx-ecosystem expectations were written against (sct/expand-inactive).
1644
+ // Which of the two is chosen as the display is settled by
1645
+ // Designations.preferredDesignation(), which ranks the SNOMED synonym
1646
+ // above the FSN - not by the order they are emitted in.
1647
+ for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1648
+ for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1632
1649
  } else {
1633
1650
  // $lookup: emit every description (preferred synonym first so the
1634
1651
  // display resolves correctly; order is not otherwise significant).
@@ -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
  }
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());