fhirsmith 0.11.2 → 0.13.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/CHANGELOG.md +377 -2
- package/README.md +1 -1
- package/config-template.json +31 -1
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +208 -30
- package/publisher/validation.js +210 -0
- package/server.js +24 -15
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +5 -1
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +141 -58
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/parameter-check.js +139 -0
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +677 -15
- package/tx/tx.fhir.org.yml +3 -2
- package/tx/tx.js +72 -92
- package/tx/workers/batch-validate.js +11 -18
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +87 -17
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +108 -60
- package/tx/workers/lookup.js +35 -40
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +472 -47
- package/tx/workers/validate.js +83 -78
- package/tx/workers/worker.js +92 -11
- package/tx/xml/xml-base.js +264 -77
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/tx/xversion/xv-valueset.js +39 -1
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
|
@@ -274,17 +274,23 @@ class LoincModule extends BaseTerminologyModule {
|
|
|
274
274
|
throw new Error(`Source directory not found: ${sourceDir}`);
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// The accessory files are required, not optional. Without them the import silently
|
|
278
|
+
// succeeds and the server then answers questions wrongly rather than saying it cannot:
|
|
279
|
+
// no ComponentHierarchyBySystem means an empty closure and $subsumes replying
|
|
280
|
+
// 'not-subsumed' to everything, which is indistinguishable from a real answer.
|
|
277
281
|
const requiredFiles = [
|
|
278
282
|
'LoincTable/Loinc.csv',
|
|
279
|
-
'AccessoryFiles/PartFile/Part.csv'
|
|
280
|
-
];
|
|
281
|
-
|
|
282
|
-
const optionalFiles = [
|
|
283
|
+
'AccessoryFiles/PartFile/Part.csv',
|
|
283
284
|
'AccessoryFiles/ConsumerName/ConsumerName.csv',
|
|
284
285
|
'AccessoryFiles/AnswerFile/AnswerList.csv',
|
|
285
286
|
'AccessoryFiles/PartFile/LoincPartLink_Primary.csv',
|
|
286
287
|
'AccessoryFiles/AnswerFile/LoincAnswerListLink.csv',
|
|
287
|
-
'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv'
|
|
288
|
+
'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv'
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
// Translations are a different case: a main-only import is a legitimate thing to want,
|
|
292
|
+
// and their absence costs designations, not correctness
|
|
293
|
+
const optionalFiles = [
|
|
288
294
|
'AccessoryFiles/LinguisticVariants/LinguisticVariants.csv'
|
|
289
295
|
];
|
|
290
296
|
|
|
@@ -1214,8 +1220,9 @@ class LoincDataMigrator {
|
|
|
1214
1220
|
async processHierarchy(db, sourceDir, step, options) {
|
|
1215
1221
|
const filePath = path.join(sourceDir, 'AccessoryFiles/ComponentHierarchyBySystem/ComponentHierarchyBySystem.csv');
|
|
1216
1222
|
if (!fs.existsSync(filePath)) {
|
|
1217
|
-
if
|
|
1218
|
-
|
|
1223
|
+
// Validation should have caught this already; if we get here, fail rather than build a
|
|
1224
|
+
// cache with an empty closure that answers subsumption questions with a confident no
|
|
1225
|
+
throw new Error(`Required file missing: ${filePath}`);
|
|
1219
1226
|
}
|
|
1220
1227
|
|
|
1221
1228
|
if (options.verbose) console.log('Processing Hierarchy...');
|
|
@@ -13,6 +13,9 @@ const {
|
|
|
13
13
|
} = require('../sct/structures');
|
|
14
14
|
const {SnomedExpressionServices} = require("../sct/expressions");
|
|
15
15
|
|
|
16
|
+
// definitionStatusId values from the RF2 concept file
|
|
17
|
+
const SNOMED_DEFINITION_STATUS_PRIMITIVE = 900000000000074008n;
|
|
18
|
+
|
|
16
19
|
class SnomedModule extends BaseTerminologyModule {
|
|
17
20
|
|
|
18
21
|
constructor() {
|
|
@@ -1342,7 +1345,13 @@ class SnomedImporter {
|
|
|
1342
1345
|
|
|
1343
1346
|
for (let i = 0; i < this.conceptList.length; i++) {
|
|
1344
1347
|
const concept = this.conceptList[i];
|
|
1345
|
-
|
|
1348
|
+
// Bits 0-3 are the status (MASK_CONCEPT_STATUS); bit 4 (MASK_CONCEPT_PRIMITIVE) says the
|
|
1349
|
+
// concept is primitive. Without the primitive bit every concept looks fully defined, and
|
|
1350
|
+
// normal form generation then runs for concepts that have no definition to expand.
|
|
1351
|
+
let flags = concept.active ? 0 : 1;
|
|
1352
|
+
if (concept.definitionStatusId === SNOMED_DEFINITION_STATUS_PRIMITIVE) {
|
|
1353
|
+
flags |= SnomedConceptList.MASK_CONCEPT_PRIMITIVE;
|
|
1354
|
+
}
|
|
1346
1355
|
const effectiveTime = this.convertDateToSnomedDate(concept.effectiveTime);
|
|
1347
1356
|
|
|
1348
1357
|
concept.index = this.concepts.addConcept(concept.id, effectiveTime, flags);
|
package/tx/library/codesystem.js
CHANGED
|
@@ -3,6 +3,7 @@ const {CanonicalResource} = require("./canonical-resource");
|
|
|
3
3
|
const {codeSystemFromR5, codeSystemToR5} = require("../xversion/xv-codesystem");
|
|
4
4
|
const {getValuePrimitive} = require("../../library/utilities");
|
|
5
5
|
const {VersionUtilities} = require("../../library/version-utilities");
|
|
6
|
+
const {countNested} = require("./nesting-limit");
|
|
6
7
|
|
|
7
8
|
const CodeSystemContentMode = Object.freeze({
|
|
8
9
|
Complete: 'complete',
|
|
@@ -33,18 +34,30 @@ class CodeSystem extends CanonicalResource {
|
|
|
33
34
|
super(jsonObj, fhirVersion);
|
|
34
35
|
// Convert to R5 format internally (modifies input for performance)
|
|
35
36
|
this.jsonObj = codeSystemToR5(this.jsonObj, fhirVersion);
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
try {
|
|
38
|
+
// Bound the nesting before anything walks the concept tree recursively - see
|
|
39
|
+
// nesting-limit.js. It has to precede validate(), which recurses itself, and it
|
|
40
|
+
// runs on the noMaps path too: validate() is skipped there, but the count below
|
|
41
|
+
// and every later consumer still walk the tree. The count is a by-product, so
|
|
42
|
+
// callers (e.g. the resource cache) get a cheap O(1) sense of the resource size
|
|
43
|
+
// without a second traversal.
|
|
44
|
+
// Optional chaining because this runs ahead of validate()'s "expected object"
|
|
45
|
+
// guard, and a null body must still get that message rather than a TypeError.
|
|
46
|
+
this._conceptCount = countNested(this.jsonObj?.concept, 'concept', 'Invalid CodeSystem: concept');
|
|
47
|
+
if (!noMaps) {
|
|
38
48
|
this.validate();
|
|
39
|
-
} catch (e) {
|
|
40
|
-
const id = this.jsonObj?.url ? `${this.jsonObj.url}|${this.jsonObj.version || ''}` : this.jsonObj?.name || 'unknown';
|
|
41
|
-
throw new Error(`${e.message} (in ${id})`);
|
|
42
49
|
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
const id = this.jsonObj?.url ? `${this.jsonObj.url}|${this.jsonObj.version || ''}` : this.jsonObj?.name || 'unknown';
|
|
52
|
+
const wrapped = new Error(`${e.message} (in ${id})`);
|
|
53
|
+
// Don't let the rewrap swallow the HTTP shape the thrower asked for.
|
|
54
|
+
if (e.statusCode) { wrapped.statusCode = e.statusCode; }
|
|
55
|
+
if (e.issueCode) { wrapped.issueCode = e.issueCode; }
|
|
56
|
+
throw wrapped;
|
|
57
|
+
}
|
|
58
|
+
if (!noMaps) {
|
|
43
59
|
this.buildMaps();
|
|
44
60
|
}
|
|
45
|
-
// Precalculated at construction so callers (e.g. the resource cache) have a
|
|
46
|
-
// cheap O(1) sense of how large this resource is.
|
|
47
|
-
this._conceptCount = CodeSystem._countConcepts(this.jsonObj.concept);
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
/**
|
|
@@ -56,25 +69,6 @@ class CodeSystem extends CanonicalResource {
|
|
|
56
69
|
return this._conceptCount;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
|
-
/**
|
|
60
|
-
* Recursively count concepts (including nested `concept` children).
|
|
61
|
-
* @param {Array} concepts
|
|
62
|
-
* @returns {number}
|
|
63
|
-
*/
|
|
64
|
-
static _countConcepts(concepts) {
|
|
65
|
-
if (!Array.isArray(concepts)) {
|
|
66
|
-
return 0;
|
|
67
|
-
}
|
|
68
|
-
let n = 0;
|
|
69
|
-
for (const c of concepts) {
|
|
70
|
-
n++;
|
|
71
|
-
if (c && c.concept) {
|
|
72
|
-
n += CodeSystem._countConcepts(c.concept);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return n;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
72
|
/**
|
|
79
73
|
* Map of code to concept object for fast lookup
|
|
80
74
|
* @type {Map<string, Object>}
|
package/tx/library/conceptmap.js
CHANGED
|
@@ -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);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds on how deeply a resource may nest itself.
|
|
3
|
+
*
|
|
4
|
+
* FHIR lets both CodeSystem.concept and ValueSet.expansion.contains nest without
|
|
5
|
+
* limit, and both are walked recursively all over this codebase (the expansion map,
|
|
6
|
+
* the concept count, the renderer, the expander's index, the R5->R4 converter, ...).
|
|
7
|
+
* A hostile resource of a few hundred KB can therefore blow the JS stack, and even
|
|
8
|
+
* if every one of those walkers were made iterative, `JSON.stringify` -- which we do
|
|
9
|
+
* not control -- overflows at a nesting depth of roughly 1800 on node 22.
|
|
10
|
+
*
|
|
11
|
+
* Rather than harden each walker in turn and hope the next one added remembers, we
|
|
12
|
+
* bound the nesting once, when the resource is constructed. Everything downstream is
|
|
13
|
+
* then safe by construction, including code that does not exist yet.
|
|
14
|
+
*
|
|
15
|
+
* The limit is deliberately far below where anything actually breaks. Real nesting
|
|
16
|
+
* mirrors the hierarchy of a code system -- SNOMED CT is about 30 deep, LOINC parts
|
|
17
|
+
* about 10, ICD-10 about 5 -- so 100 is well past any legitimate resource while
|
|
18
|
+
* leaving more than an order of magnitude of headroom against the stack.
|
|
19
|
+
*
|
|
20
|
+
* @module tx/library/nesting-limit
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Maximum nesting depth allowed in a resource's self-recursive structure.
|
|
25
|
+
* @type {number}
|
|
26
|
+
*/
|
|
27
|
+
const MAX_NESTING_DEPTH = 100;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Walks a self-nesting FHIR structure iteratively, counting its entries and
|
|
31
|
+
* enforcing {@link MAX_NESTING_DEPTH}.
|
|
32
|
+
*
|
|
33
|
+
* The walk uses an explicit stack, so it costs no JS stack depth of its own and can
|
|
34
|
+
* safely be pointed at a tree deep enough to overflow a recursive walker. It is the
|
|
35
|
+
* one traversal that has to survive hostile input; every other walker in the codebase
|
|
36
|
+
* runs after this one has passed.
|
|
37
|
+
*
|
|
38
|
+
* Non-array input counts as zero, matching the tolerant behaviour of the recursive
|
|
39
|
+
* counters this replaces - structural type checking is the caller's business.
|
|
40
|
+
*
|
|
41
|
+
* @param {Array} items - Top-level array of the nesting structure
|
|
42
|
+
* @param {string} childProperty - Name of the property holding nested children ('concept', 'contains')
|
|
43
|
+
* @param {string} path - Path used in the error message ('CodeSystem.concept')
|
|
44
|
+
* @returns {number} Total number of entries, nested ones included
|
|
45
|
+
* @throws {Error} If nesting exceeds MAX_NESTING_DEPTH, tagged statusCode 400 / issueCode 'structure'
|
|
46
|
+
*/
|
|
47
|
+
function countNested(items, childProperty, path) {
|
|
48
|
+
if (!Array.isArray(items)) {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let count = 0;
|
|
53
|
+
// Each frame is a list to walk plus the depth at which its entries sit.
|
|
54
|
+
const stack = [{ list: items, depth: 1 }];
|
|
55
|
+
|
|
56
|
+
while (stack.length > 0) {
|
|
57
|
+
const { list, depth } = stack.pop();
|
|
58
|
+
if (depth > MAX_NESTING_DEPTH) {
|
|
59
|
+
// The worker error handlers honour statusCode/issueCode when they are present on
|
|
60
|
+
// a plain Error, so tag it: this is a bad resource from the client, not a server
|
|
61
|
+
// fault, and it should come back as a 400 rather than the default 500/exception.
|
|
62
|
+
const e = new Error(
|
|
63
|
+
`${path} is nested more than ${MAX_NESTING_DEPTH} levels deep, which is not supported`);
|
|
64
|
+
e.statusCode = 400;
|
|
65
|
+
e.issueCode = 'structure';
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
for (const item of list) {
|
|
69
|
+
count++;
|
|
70
|
+
if (item && Array.isArray(item[childProperty]) && item[childProperty].length > 0) {
|
|
71
|
+
stack.push({ list: item[childProperty], depth: depth + 1 });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return count;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { MAX_NESTING_DEPTH, countNested };
|
|
@@ -1,5 +1,80 @@
|
|
|
1
1
|
const {validateParameter} = require("../../library/utilities");
|
|
2
2
|
|
|
3
|
+
const TX_ISSUE_TYPE_SYSTEM = "http://hl7.org/fhir/tools/CodeSystem/tx-issue-type";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every issue this server emits carries a tx-issue-type coding in details.coding, and the
|
|
7
|
+
* human readable account of the problem in details.text. Nothing a client needs is allowed
|
|
8
|
+
* to live *only* in diagnostics: the test harness strips diagnostics outright, so anything
|
|
9
|
+
* that lives there alone is, by construction, invisible to a test. diagnostics itself is
|
|
10
|
+
* still sent -- it is the right place for server-specific detail, and dropping it would
|
|
11
|
+
* take away something useful to get a property no test could have relied on anyway.
|
|
12
|
+
*
|
|
13
|
+
* Most issues name their tx-issue-type explicitly. This map is the backstop for the ones
|
|
14
|
+
* that do not, so that "always has a coding" holds without every one of the ~170 Issue
|
|
15
|
+
* construction sites having to be revisited at once. It is keyed on the FHIR issue-type,
|
|
16
|
+
* which is the coarser of the two vocabularies, so an explicit code is always better.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_TX_ISSUE_TYPE = {
|
|
19
|
+
'not-found': 'not-found',
|
|
20
|
+
'code-invalid': 'invalid-code',
|
|
21
|
+
'invalid': 'invalid-data',
|
|
22
|
+
'structure': 'invalid-data',
|
|
23
|
+
'required': 'invalid-data',
|
|
24
|
+
'value': 'invalid-data',
|
|
25
|
+
'processing': 'invalid-data',
|
|
26
|
+
'exception': 'invalid-data',
|
|
27
|
+
'unknown': 'invalid-data',
|
|
28
|
+
'conflict': 'invalid-data',
|
|
29
|
+
'business-rule': 'business-rule',
|
|
30
|
+
'too-costly': 'too-costly',
|
|
31
|
+
'not-supported': 'not-supported',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The tx-issue-type for an issue that did not name one. Informational issues that are not
|
|
36
|
+
* about a code or a value set -- the server banner, say -- are not tx issues at all and get
|
|
37
|
+
* no coding; everything else does.
|
|
38
|
+
*/
|
|
39
|
+
function defaultTxIssueType(code, severity) {
|
|
40
|
+
if (severity === 'information' && (code === 'informational' || !code)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return DEFAULT_TX_ISSUE_TYPE[code] || 'invalid-data';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a single issue from loose parts. Prefer throwing an Issue -- this is for the
|
|
48
|
+
* handful of places that build an outcome directly, so that they cannot drift away from
|
|
49
|
+
* the shape Issue.asIssue() produces.
|
|
50
|
+
*
|
|
51
|
+
* `diagnostics` still goes out on the wire. The rule is that nothing a client needs may
|
|
52
|
+
* live *only* there, because the test harness strips it before it compares -- not that the
|
|
53
|
+
* server should stop saying anything. Callers with real internal detail to add (a stack
|
|
54
|
+
* location, a request id, what the server was doing when it gave up) pass it; the rest get
|
|
55
|
+
* the message, which is what this server has always sent.
|
|
56
|
+
*/
|
|
57
|
+
function buildIssue(severity, code, message, txIssueType = null, diagnostics = undefined) {
|
|
58
|
+
const res = { severity, code, details: { text: message } };
|
|
59
|
+
const tx = txIssueType || defaultTxIssueType(code, severity);
|
|
60
|
+
if (tx) {
|
|
61
|
+
res.details.coding = [{ system: TX_ISSUE_TYPE_SYSTEM, code: tx }];
|
|
62
|
+
}
|
|
63
|
+
const diag = diagnostics === undefined ? message : diagnostics;
|
|
64
|
+
if (diag) {
|
|
65
|
+
res.diagnostics = diag;
|
|
66
|
+
}
|
|
67
|
+
return res;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build a one issue OperationOutcome from loose parts. */
|
|
71
|
+
function buildOperationOutcome(severity, code, message, txIssueType = null, diagnostics = undefined) {
|
|
72
|
+
return {
|
|
73
|
+
resourceType: 'OperationOutcome',
|
|
74
|
+
issue: [buildIssue(severity, code, message, txIssueType, diagnostics)],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
3
78
|
class Issue extends Error {
|
|
4
79
|
level;
|
|
5
80
|
cause;
|
|
@@ -33,8 +108,9 @@ class Issue extends Error {
|
|
|
33
108
|
if (this.path) {
|
|
34
109
|
res.expression = [this.path]
|
|
35
110
|
}
|
|
36
|
-
|
|
37
|
-
|
|
111
|
+
const issueCode = this.issueCode || defaultTxIssueType(this.cause, this.level);
|
|
112
|
+
if (issueCode) {
|
|
113
|
+
res.details.coding = [{ system: TX_ISSUE_TYPE_SYSTEM, code : issueCode }];
|
|
38
114
|
}
|
|
39
115
|
if (this.msgId) {
|
|
40
116
|
res.extension = [{ url: "http://hl7.org/fhir/StructureDefinition/operationoutcome-message-id", valueString: this.msgId }];
|
|
@@ -129,4 +205,22 @@ class OperationOutcome {
|
|
|
129
205
|
}
|
|
130
206
|
}
|
|
131
207
|
|
|
132
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Turn a thrown error into an OperationOutcome. Two conventions meet here, and this is the
|
|
210
|
+
* only place that has to know about both: an Issue carries the FHIR issue type in `cause`
|
|
211
|
+
* and the tx-issue-type in `issueCode`, while a plain Error tagged by whatever threw it
|
|
212
|
+
* carries the FHIR issue type in `issueCode` and the tx-issue-type in `txIssueType`. Either
|
|
213
|
+
* way the result has details.text and a tx-issue-type coding.
|
|
214
|
+
*/
|
|
215
|
+
function outcomeFromError(error) {
|
|
216
|
+
if (error instanceof Issue) {
|
|
217
|
+
const oo = new OperationOutcome();
|
|
218
|
+
oo.addIssue(error);
|
|
219
|
+
return oo.jsonObj;
|
|
220
|
+
}
|
|
221
|
+
return buildOperationOutcome('error', error.issueCode || 'exception',
|
|
222
|
+
error.message || String(error), error.txIssueType || null, error.diagnostics);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { OperationOutcome, Issue, buildIssue, buildOperationOutcome,
|
|
226
|
+
outcomeFromError, defaultTxIssueType, TX_ISSUE_TYPE_SYSTEM };
|
|
@@ -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/valueset.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const {CanonicalResource} = require("./canonical-resource");
|
|
2
2
|
const {valueSetToR5, valueSetFromR5} = require("../xversion/xv-valueset");
|
|
3
|
+
const {countNested} = require("./nesting-limit");
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Represents a FHIR ValueSet resource with version conversion support
|
|
@@ -27,7 +28,7 @@ class ValueSet extends CanonicalResource {
|
|
|
27
28
|
this.buildMaps();
|
|
28
29
|
// Precalculated at construction so callers (e.g. the resource cache) have a
|
|
29
30
|
// cheap O(1) sense of how large this resource is.
|
|
30
|
-
this._conceptCount = ValueSet._countConcepts(this.jsonObj);
|
|
31
|
+
this._conceptCount = ValueSet._countConcepts(this.jsonObj, this._containsCount);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -42,7 +43,7 @@ class ValueSet extends CanonicalResource {
|
|
|
42
43
|
return this._conceptCount;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
static _countConcepts(jsonObj) {
|
|
46
|
+
static _countConcepts(jsonObj, containsCount) {
|
|
46
47
|
let n = 0;
|
|
47
48
|
const compose = jsonObj && jsonObj.compose;
|
|
48
49
|
if (compose) {
|
|
@@ -53,24 +54,7 @@ class ValueSet extends CanonicalResource {
|
|
|
53
54
|
n += Array.isArray(exc.concept) ? exc.concept.length : 0;
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
-
n += ValueSet._countContains(jsonObj.expansion.contains);
|
|
58
|
-
}
|
|
59
|
-
return n;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
static _countContains(items) {
|
|
63
|
-
if (!Array.isArray(items)) {
|
|
64
|
-
return 0;
|
|
65
|
-
}
|
|
66
|
-
let n = 0;
|
|
67
|
-
for (const it of items) {
|
|
68
|
-
n++;
|
|
69
|
-
if (it && it.contains) {
|
|
70
|
-
n += ValueSet._countContains(it.contains);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return n;
|
|
57
|
+
return n + (containsCount || 0);
|
|
74
58
|
}
|
|
75
59
|
|
|
76
60
|
/**
|
|
@@ -79,6 +63,13 @@ class ValueSet extends CanonicalResource {
|
|
|
79
63
|
*/
|
|
80
64
|
codeMap = new Map();
|
|
81
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Number of entries in expansion.contains, nested ones included. Counted by the
|
|
68
|
+
* depth-bounding walk in validate(), so the tree is only traversed once.
|
|
69
|
+
* @type {number}
|
|
70
|
+
*/
|
|
71
|
+
_containsCount = 0;
|
|
72
|
+
|
|
82
73
|
/**
|
|
83
74
|
* Static factory method for convenience
|
|
84
75
|
* @param {string} jsonString - JSON string representation of ValueSet
|
|
@@ -158,6 +149,11 @@ class ValueSet extends CanonicalResource {
|
|
|
158
149
|
if (this.jsonObj.expansion.contains && !Array.isArray(this.jsonObj.expansion.contains)) {
|
|
159
150
|
throw new Error('Invalid ValueSet: expansion.contains must be an array if present');
|
|
160
151
|
}
|
|
152
|
+
// Bound the nesting before anything walks it recursively - see nesting-limit.js.
|
|
153
|
+
// This has to happen before buildMaps(), which is itself a recursive walker, so
|
|
154
|
+
// it lives here rather than alongside the concept count in the constructor.
|
|
155
|
+
this._containsCount = countNested(this.jsonObj.expansion.contains, 'contains',
|
|
156
|
+
'Invalid ValueSet: expansion.contains');
|
|
161
157
|
}
|
|
162
158
|
}
|
|
163
159
|
|
package/tx/library.js
CHANGED
|
@@ -23,6 +23,7 @@ const {UniiServicesFactory} = require("./cs/cs-unii");
|
|
|
23
23
|
const {SnomedServicesFactory} = require("./cs/cs-snomed");
|
|
24
24
|
const {CPTServicesFactory} = require("./cs/cs-cpt");
|
|
25
25
|
const {OMOPServicesFactory} = require("./cs/cs-omop");
|
|
26
|
+
const {ICD11ServicesFactory} = require("./cs/cs-icd11");
|
|
26
27
|
const {PackageValueSetProvider} = require("./vs/vs-package");
|
|
27
28
|
const {PackageConceptMapProvider} = require("./cm/cm-package");
|
|
28
29
|
const {IETFLanguageCodeFactory} = require("./cs/cs-lang");
|
|
@@ -277,6 +278,10 @@ class Library {
|
|
|
277
278
|
await this.loadOmop(details, isDefault, mode);
|
|
278
279
|
break;
|
|
279
280
|
|
|
281
|
+
case 'icd11':
|
|
282
|
+
await this.loadIcd11(details, isDefault, mode);
|
|
283
|
+
break;
|
|
284
|
+
|
|
280
285
|
case 'npm':
|
|
281
286
|
await this.loadNpm(packageManager, details, isDefault, mode, false);
|
|
282
287
|
break;
|
|
@@ -457,7 +462,7 @@ class Library {
|
|
|
457
462
|
break;
|
|
458
463
|
}
|
|
459
464
|
case "mimetypes" : {
|
|
460
|
-
const mime = new MimeTypeServicesFactory(this.i18n);
|
|
465
|
+
const mime = new MimeTypeServicesFactory(this.i18n, this.cacheFolder, this.log);
|
|
461
466
|
await mime.load();
|
|
462
467
|
this.registerProvider('internal', mime);
|
|
463
468
|
break;
|
|
@@ -591,6 +596,23 @@ class Library {
|
|
|
591
596
|
this.registerProvider(omopFN, omop, isDefault);
|
|
592
597
|
}
|
|
593
598
|
|
|
599
|
+
/**
|
|
600
|
+
* ICD-11 is three code systems in one database - the MMS and ICF linearizations and the
|
|
601
|
+
* Foundation - so one source line registers a factory per system that the database
|
|
602
|
+
* actually contains. registerProvider is keyed on system(), so they cannot share one.
|
|
603
|
+
*/
|
|
604
|
+
async loadIcd11(details, isDefault, mode) {
|
|
605
|
+
const icdFN = await this.getOrDownloadFile(details);
|
|
606
|
+
if (mode === "fetch" || mode === "npm") {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
for (const sys of await ICD11ServicesFactory.listSystems(icdFN)) {
|
|
610
|
+
const factory = new ICD11ServicesFactory(this.i18n, icdFN, sys.code);
|
|
611
|
+
await factory.load();
|
|
612
|
+
this.registerProvider(icdFN, factory, isDefault);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
594
616
|
/**
|
|
595
617
|
* Returns true if the given url/version should be excluded from npm/url package loading.
|
|
596
618
|
* Matches against the ignored list using either plain url or url#version.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const fs = require('fs/promises');
|
|
2
2
|
const fsSync = require('fs');
|
|
3
|
+
const Logger = require('../../../library/logger');
|
|
4
|
+
const oclLog = Logger.getInstance().child({ module: 'ocl' });
|
|
3
5
|
|
|
4
6
|
async function ensureCacheDirectories(...dirs) {
|
|
5
7
|
for (const dir of dirs) {
|
|
@@ -10,12 +12,12 @@ async function ensureCacheDirectories(...dirs) {
|
|
|
10
12
|
try {
|
|
11
13
|
await fs.mkdir(dir, { recursive: true });
|
|
12
14
|
} catch (error) {
|
|
13
|
-
|
|
15
|
+
oclLog.error(`Failed to create cache directory: ${dir} ${error.message}`);
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
function getColdCacheAgeMs(cacheFilePath
|
|
20
|
+
function getColdCacheAgeMs(cacheFilePath) {
|
|
19
21
|
try {
|
|
20
22
|
const stats = fsSync.statSync(cacheFilePath);
|
|
21
23
|
if (!stats || !Number.isFinite(stats.mtimeMs)) {
|
|
@@ -25,7 +27,7 @@ function getColdCacheAgeMs(cacheFilePath, logPrefix = '[OCL]') {
|
|
|
25
27
|
return Math.max(0, Date.now() - stats.mtimeMs);
|
|
26
28
|
} catch (error) {
|
|
27
29
|
if (error && error.code !== 'ENOENT') {
|
|
28
|
-
|
|
30
|
+
oclLog.error(`Failed to inspect cold cache file ${cacheFilePath}: ${error.message}`);
|
|
29
31
|
}
|
|
30
32
|
return null;
|
|
31
33
|
}
|