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 +15 -0
- package/library/folder-content-loader.js +111 -21
- package/package.json +3 -3
- package/tx/cs/cs-api.js +9 -3
- package/tx/cs/cs-snomed.js +28 -11
- package/tx/library/designations.js +9 -2
- package/tx/library.js +62 -9
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
|
-
*
|
|
30
|
-
*
|
|
31
|
+
* The synthesized package name reported by id()/pid().
|
|
32
|
+
* @returns {string}
|
|
31
33
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
4
|
-
"txVersion": "1.9.
|
|
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.
|
|
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
|
|
408
|
-
*
|
|
409
|
-
*
|
|
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)
|
package/tx/cs/cs-snomed.js
CHANGED
|
@@ -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
|
|
1609
|
-
// the preferred synonym
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
|
|
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 (!
|
|
1635
|
+
if (!fsns.has(langCode)) fsns.set(langCode, { langCode, use, term });
|
|
1624
1636
|
} else if (this.sct._synonymIsPreferred(description)) {
|
|
1625
|
-
if (!
|
|
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
|
-
|
|
1631
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
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());
|