fhirsmith 0.16.1 → 0.18.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 +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/readme.md +1 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +107 -6
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The terminology view of an IPS document: every CodeableConcept in it, in a fixed order
|
|
9
|
+
* - the Composition's own, then section by section in the IPS order (whatever order the
|
|
10
|
+
* sections come in), then any other sections in document order - and, within a section,
|
|
11
|
+
* entry by entry, element by element.
|
|
12
|
+
*
|
|
13
|
+
* CodeableConcepts are found by walking each resource with the R4 type model fhirpath.js
|
|
14
|
+
* ships (path -> type), so that an element is reported as a CodeableConcept because the
|
|
15
|
+
* specification says it is one, not because it happens to look like one; for a resource
|
|
16
|
+
* or element the model doesn't know, an object with a `coding` array is taken to be one.
|
|
17
|
+
* Section codes are not reported (the section itself says what they are), nor are the
|
|
18
|
+
* elements in IGNORED below.
|
|
19
|
+
*
|
|
20
|
+
* @module ips/terminology
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const r4Model = require('fhirpath/fhir-context/r4');
|
|
24
|
+
const { compositionOf, resolveInBundle, bundleIndex } = require('./search');
|
|
25
|
+
const { editionCode, editionName } = require('../tx/sct/editions');
|
|
26
|
+
|
|
27
|
+
// the IPS sections, in the order the view presents them: [LOINC code, title, words that
|
|
28
|
+
// identify the section by its title when the code is missing or different]
|
|
29
|
+
const SECTION_ORDER = [
|
|
30
|
+
['81338-6', 'Patient Story', /patient story/i],
|
|
31
|
+
['11450-4', 'Problems', /^(active )?problem/i],
|
|
32
|
+
['48765-2', 'Allergies and Intolerances', /allerg/i],
|
|
33
|
+
['10160-0', 'Medication Summary', /medication/i],
|
|
34
|
+
['11369-6', 'Immunizations', /immuni[sz]ation|vaccin/i],
|
|
35
|
+
['47519-4', 'History of Procedures', /procedure/i],
|
|
36
|
+
['11348-0', 'History of Past Problems', /past (problem|illness)|history of past/i],
|
|
37
|
+
['46264-8', 'Medical Devices', /device/i],
|
|
38
|
+
['42348-3', 'Advance Directives', /advance directive/i],
|
|
39
|
+
['104605-1', 'Alerts', /^alert/i],
|
|
40
|
+
['47420-5', 'Functional Status', /functional status/i],
|
|
41
|
+
['10162-6', 'Pregnancy History', /pregnan/i],
|
|
42
|
+
['29762-2', 'Social History', /social history/i],
|
|
43
|
+
['18776-5', 'Plan of Care', /plan of (care|treatment)|care plan/i],
|
|
44
|
+
['8716-3', 'Vital Signs', /vital sign/i],
|
|
45
|
+
['30954-2', 'Results', /^results?$|diagnostic results|relevant diagnostic/i]
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// elements whose CodeableConcepts are not interesting: resource type + path within it
|
|
49
|
+
// (without indices), or *.name for an element of that name anywhere
|
|
50
|
+
const IGNORED = new Set([
|
|
51
|
+
'*.category',
|
|
52
|
+
'Composition.type',
|
|
53
|
+
'Composition.event.code',
|
|
54
|
+
'AllergyIntolerance.clinicalStatus',
|
|
55
|
+
'AllergyIntolerance.verificationStatus',
|
|
56
|
+
'Condition.clinicalStatus',
|
|
57
|
+
'Condition.verificationStatus'
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** Is this element (an index-free path) on the ignore list? */
|
|
61
|
+
function isIgnored(element) {
|
|
62
|
+
return IGNORED.has(element) || IGNORED.has('*' + element.substring(element.lastIndexOf('.')));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// the external code systems - the big terminologies that no FHIR server holds as a
|
|
66
|
+
// CodeSystem resource, so there is nothing to link to - with their names for the display.
|
|
67
|
+
// Anything else under terminology.hl7.org or hl7.org/fhir is named by its last path segment
|
|
68
|
+
const SYSTEM_NAMES = {
|
|
69
|
+
'http://snomed.info/sct': 'SNOMED CT',
|
|
70
|
+
'http://loinc.org': 'LOINC',
|
|
71
|
+
'http://www.nlm.nih.gov/research/umls/rxnorm': 'RxNorm',
|
|
72
|
+
'http://unitsofmeasure.org': 'UCUM',
|
|
73
|
+
'http://www.whocc.no/atc': 'ATC',
|
|
74
|
+
'http://hl7.org/fhir/sid/icd-10': 'ICD-10',
|
|
75
|
+
'http://hl7.org/fhir/sid/icd-10-cm': 'ICD-10-CM',
|
|
76
|
+
'http://hl7.org/fhir/sid/icd-9-cm': 'ICD-9-CM',
|
|
77
|
+
'http://hl7.org/fhir/sid/icd-9': 'ICD-9',
|
|
78
|
+
'http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl': 'NCI Thesaurus',
|
|
79
|
+
'http://id.who.int/icd/release/11/mms': 'ICD-11 MMS',
|
|
80
|
+
'http://id.who.int/icd11/mms': 'ICD-11 MMS',
|
|
81
|
+
'http://hl7.org/fhir/sid/cvx': 'CVX',
|
|
82
|
+
'http://hl7.org/fhir/sid/ndc': 'NDC',
|
|
83
|
+
'http://www.ama-assn.org/go/cpt': 'CPT',
|
|
84
|
+
'http://fdasis.nlm.nih.gov': 'UNII',
|
|
85
|
+
'http://www.radlex.org': 'RadLex',
|
|
86
|
+
'http://dicom.nema.org/resources/ontology/DCM': 'DICOM',
|
|
87
|
+
'urn:ietf:bcp:47': 'BCP 47 (language)',
|
|
88
|
+
'urn:iso:std:iso:3166': 'ISO 3166 (country)',
|
|
89
|
+
'urn:ietf:rfc:3986': 'URI',
|
|
90
|
+
'http://unstats.un.org/unsd/methods/m49/m49.htm': 'UN M49',
|
|
91
|
+
'http://standardterms.edqm.eu': 'EDQM',
|
|
92
|
+
'https://www.gs1.org/gtin': 'GTIN',
|
|
93
|
+
'http://www.nlm.nih.gov/research/umls': 'UMLS',
|
|
94
|
+
'http://varnomen.hgvs.org': 'HGVS',
|
|
95
|
+
'http://www.genenames.org': 'HGNC',
|
|
96
|
+
'http://sequenceontology.org': 'Sequence Ontology',
|
|
97
|
+
'http://purl.obolibrary.org/obo/hp.owl': 'HPO',
|
|
98
|
+
'http://www.orpha.net': 'Orphanet'
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Is this one of the external code systems (SNOMED CT, LOINC, ...)? */
|
|
102
|
+
function isExternalSystem(url) {
|
|
103
|
+
return typeof url === 'string' && Object.prototype.hasOwnProperty.call(SYSTEM_NAMES, url);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A readable name for a code system URL - DataRenderer.displaySystem, less the lookup of
|
|
108
|
+
* a CodeSystem resource's title (the tx server might know one, but that's a query away).
|
|
109
|
+
*/
|
|
110
|
+
function systemName(url) {
|
|
111
|
+
if (typeof url !== 'string' || url === '') {
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
if (url.startsWith('http://snomed.info')) {
|
|
115
|
+
return 'SNOMED CT';
|
|
116
|
+
}
|
|
117
|
+
if (SYSTEM_NAMES[url]) {
|
|
118
|
+
return SYSTEM_NAMES[url];
|
|
119
|
+
}
|
|
120
|
+
const m = /^https?:\/\/(?:terminology\.hl7\.org\/CodeSystem|hl7\.org\/fhir(?:\/[a-z-]+)*)\/([^/]+)$/.exec(url);
|
|
121
|
+
if (m) {
|
|
122
|
+
return m[1];
|
|
123
|
+
}
|
|
124
|
+
return url;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const SCT_MONTHS = { '01': 'Jan', '02': 'Feb', '03': 'Mar', '04': 'Apr', '05': 'May', '06': 'Jun', '07': 'Jul', '08': 'Aug', '09': 'Sep', '10': 'Oct', '11': 'Nov', '12': 'Dec' };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The edition module id in a SNOMED CT version URI (http://snomed.info/sct/[module] or
|
|
131
|
+
* .../version/[date]), or a bare module id - SnomedUtilities.getEditionFromVersion, but
|
|
132
|
+
* returning the id whether or not it is a known edition.
|
|
133
|
+
*/
|
|
134
|
+
function sctEdition(version) {
|
|
135
|
+
if (typeof version !== 'string') {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
let v = version.startsWith('http://snomed.info/sct/') ? version.substring(23) : version;
|
|
139
|
+
if (v.includes('/')) {
|
|
140
|
+
v = v.substring(0, v.indexOf('/'));
|
|
141
|
+
}
|
|
142
|
+
return /^\d{6,18}$/.test(v) ? v : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A short description of a code system version - DataRenderer.describeVersion. A SNOMED CT
|
|
147
|
+
* version URI becomes the edition's short code (from tx/sct/editions.js) and release:
|
|
148
|
+
* "US rel. Mar 2024"; anything else is returned as it is.
|
|
149
|
+
*
|
|
150
|
+
* @returns {{text: string, title: string|null}} title is the edition's full name, for a hover
|
|
151
|
+
*/
|
|
152
|
+
function describeVersion(version) {
|
|
153
|
+
if (typeof version !== 'string' || version === '') {
|
|
154
|
+
return { text: '', title: null };
|
|
155
|
+
}
|
|
156
|
+
if (!version.startsWith('http://snomed.info/sct')) {
|
|
157
|
+
return { text: version, title: null };
|
|
158
|
+
}
|
|
159
|
+
const p = version.split('/');
|
|
160
|
+
let ed;
|
|
161
|
+
let dt = '';
|
|
162
|
+
if (p.length >= 3 && p[p.length - 2] === 'version') {
|
|
163
|
+
ed = p[p.length - 3];
|
|
164
|
+
const date = p[p.length - 1];
|
|
165
|
+
if (/^\d{8}$/.test(date)) {
|
|
166
|
+
dt = ` rel. ${SCT_MONTHS[date.substring(4, 6)] || date.substring(4, 6)} ${date.substring(0, 4)}`;
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
ed = p[p.length - 1];
|
|
170
|
+
}
|
|
171
|
+
const code = editionCode(ed);
|
|
172
|
+
return { text: (code || '??') + dt, title: editionName(ed) || (ed ? `Unknown edition ${ed}` : null) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Where to read about a code system - DataRenderer.getLinkForSystem for the external
|
|
177
|
+
* ones; null for anything else (the terminology server's page is the caller's business).
|
|
178
|
+
*/
|
|
179
|
+
function linkForSystem(system) {
|
|
180
|
+
switch (system) {
|
|
181
|
+
case 'http://snomed.info/sct': return 'https://browser.ihtsdotools.org/';
|
|
182
|
+
case 'http://loinc.org': return 'https://loinc.org/';
|
|
183
|
+
case 'http://unitsofmeasure.org': return 'http://ucum.org';
|
|
184
|
+
case 'http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl': return 'https://ncit.nci.nih.gov/ncitbrowser/pages/home.jsf';
|
|
185
|
+
default: return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Where to read about a code - DataRenderer.getLinkForCode for the external code systems
|
|
191
|
+
* (SNOMED CT by SnomedUtilities.getSctLink: the edition's URI when the version names one,
|
|
192
|
+
* else http://snomed.info/id/[code]; a post-coordinated expression goes to the browser);
|
|
193
|
+
* null for anything else.
|
|
194
|
+
*/
|
|
195
|
+
function linkForCode(system, version, code) {
|
|
196
|
+
const has = typeof code === 'string' && code !== '';
|
|
197
|
+
switch (system) {
|
|
198
|
+
case 'http://snomed.info/sct': {
|
|
199
|
+
if (!has || code.includes(':')) {
|
|
200
|
+
return 'https://browser.ihtsdotools.org/';
|
|
201
|
+
}
|
|
202
|
+
const ed = sctEdition(version);
|
|
203
|
+
return ed && editionName(ed) ? `http://snomed.info/sct/${ed}/id/${encodeURIComponent(code)}` : `http://snomed.info/id/${encodeURIComponent(code)}`;
|
|
204
|
+
}
|
|
205
|
+
case 'http://loinc.org':
|
|
206
|
+
return has ? `https://loinc.org/${encodeURIComponent(code)}` : 'https://loinc.org/';
|
|
207
|
+
case 'http://www.nlm.nih.gov/research/umls/rxnorm':
|
|
208
|
+
return has ? `https://mor.nlm.nih.gov/RxNav/search?searchBy=RXCUI&searchTerm=${encodeURIComponent(code)}` : 'https://www.nlm.nih.gov/research/umls/rxnorm/index.html';
|
|
209
|
+
case 'http://ncicb.nci.nih.gov/xml/owl/EVS/Thesaurus.owl':
|
|
210
|
+
return has ? `https://ncit.nci.nih.gov/ncitbrowser/ConceptReport.jsp?code=${encodeURIComponent(code)}` : 'https://ncit.nci.nih.gov/ncitbrowser/pages/home.jsf';
|
|
211
|
+
case 'urn:iso:std:iso:3166':
|
|
212
|
+
return has ? `https://en.wikipedia.org/wiki/ISO_3166-2:${encodeURIComponent(code)}` : 'https://en.wikipedia.org/wiki/ISO_3166-2';
|
|
213
|
+
default:
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isObject(v) {
|
|
219
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Does this look like a CodeableConcept, for when the model has nothing to say? */
|
|
223
|
+
function looksLikeCodeableConcept(v) {
|
|
224
|
+
return isObject(v) && Array.isArray(v.coding) && v.coding.every(c => isObject(c)) &&
|
|
225
|
+
Object.keys(v).every(k => ['coding', 'text', 'id', 'extension'].includes(k));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The type of an element, from the model. `modelPath` is the path the model knows
|
|
230
|
+
* (Type.element.element, with datatypes starting their own path); null if the model
|
|
231
|
+
* doesn't know it.
|
|
232
|
+
*/
|
|
233
|
+
function typeOf(modelPath) {
|
|
234
|
+
let p = modelPath;
|
|
235
|
+
for (let i = 0; i < 5 && p; i++) {
|
|
236
|
+
const t = r4Model.path2Type[p];
|
|
237
|
+
if (t) {
|
|
238
|
+
return { type: t, path: p };
|
|
239
|
+
}
|
|
240
|
+
// e.g. Composition.section.section is defined at Composition.section
|
|
241
|
+
p = r4Model.pathsDefinedElsewhere[p];
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Walk a resource (or datatype) collecting its CodeableConcepts.
|
|
248
|
+
*
|
|
249
|
+
* @param {Object} node - the resource or element
|
|
250
|
+
* @param {string} modelPath - the model's path for node (e.g. 'Observation.component')
|
|
251
|
+
* @param {string} shownPath - the path shown to people (e.g. 'Observation.component[1]')
|
|
252
|
+
* @param {string} resourceType - for the IGNORED check
|
|
253
|
+
* @param {Array} out
|
|
254
|
+
* @param {Set} skip - element names not to descend into, at this level
|
|
255
|
+
*/
|
|
256
|
+
function walk(node, modelPath, shownPath, resourceType, out, skip = null) {
|
|
257
|
+
if (!isObject(node)) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
for (const [key, value] of Object.entries(node)) {
|
|
261
|
+
if (key === 'resourceType' || (skip && skip.has(key)) || value === null || typeof value !== 'object') {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const resolved = typeOf(`${modelPath}.${key}`);
|
|
265
|
+
const type = resolved ? resolved.type : null;
|
|
266
|
+
const items = Array.isArray(value) ? value : [value];
|
|
267
|
+
items.forEach((item, i) => {
|
|
268
|
+
if (!isObject(item)) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const shown = `${shownPath}.${key}${Array.isArray(value) ? `[${i}]` : ''}`;
|
|
272
|
+
// the ignore list is by element path, without indices
|
|
273
|
+
const plain = shown.replace(/\[\d+\]/g, '');
|
|
274
|
+
if (type === 'CodeableConcept' || (type === null && looksLikeCodeableConcept(item))) {
|
|
275
|
+
if (!isIgnored(plain)) {
|
|
276
|
+
out.push({ path: shown, element: plain, concept: item });
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (type === null) {
|
|
281
|
+
// unknown to the model: keep looking inside, in case
|
|
282
|
+
walk(item, `${modelPath}.${key}`, shown, resourceType, out);
|
|
283
|
+
} else if (type === 'BackboneElement' || type === 'Element') {
|
|
284
|
+
walk(item, resolved.path, shown, resourceType, out);
|
|
285
|
+
} else if (/^[A-Z]/.test(type) && type !== 'Resource' && type !== 'Reference' && type !== 'Narrative') {
|
|
286
|
+
// a datatype: its own elements are defined on the type
|
|
287
|
+
walk(item, type, shown, resourceType, out);
|
|
288
|
+
}
|
|
289
|
+
// primitives, references and narratives have no CodeableConcepts in them
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The CodeableConcepts in one resource.
|
|
296
|
+
* @param {Object} resource
|
|
297
|
+
* @param {{skip?: string[]}} [options] - top-level elements to leave out
|
|
298
|
+
* @returns {Array<{path: string, element: string, concept: Object}>}
|
|
299
|
+
*/
|
|
300
|
+
function conceptsOf(resource, options = {}) {
|
|
301
|
+
const out = [];
|
|
302
|
+
if (isObject(resource) && typeof resource.resourceType === 'string') {
|
|
303
|
+
walk(resource, resource.resourceType, resource.resourceType, resource.resourceType, out, options.skip ? new Set(options.skip) : null);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Which IPS section this is, if any: its index in SECTION_ORDER, or -1. */
|
|
309
|
+
function sectionRank(section) {
|
|
310
|
+
const codes = Array.isArray(section?.code?.coding) ? section.code.coding : [];
|
|
311
|
+
for (const c of codes) {
|
|
312
|
+
if (c?.system === 'http://loinc.org' || !c?.system) {
|
|
313
|
+
const i = SECTION_ORDER.findIndex(s => s[0] === c?.code);
|
|
314
|
+
if (i >= 0) {
|
|
315
|
+
return i;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const title = typeof section?.title === 'string' ? section.title.trim() : '';
|
|
320
|
+
if (title) {
|
|
321
|
+
const i = SECTION_ORDER.findIndex(s => s[2].test(title));
|
|
322
|
+
if (i >= 0) {
|
|
323
|
+
return i;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return -1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A label for a resource: Type/id, or the entry's fullUrl, or just the type. */
|
|
330
|
+
function resourceLabel(resource, fullUrl) {
|
|
331
|
+
if (!isObject(resource)) {
|
|
332
|
+
return '';
|
|
333
|
+
}
|
|
334
|
+
if (typeof resource.id === 'string') {
|
|
335
|
+
return `${resource.resourceType}/${resource.id}`;
|
|
336
|
+
}
|
|
337
|
+
return fullUrl ? `${resource.resourceType} (${fullUrl})` : resource.resourceType;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The terminology of a document.
|
|
342
|
+
*
|
|
343
|
+
* @param {Object} bundle
|
|
344
|
+
* @returns {{composition: Array, sections: Array<{title: string, known: boolean, entries: Array<{
|
|
345
|
+
* reference: string, label: string, resolved: boolean, concepts: Array}>}>}}
|
|
346
|
+
*/
|
|
347
|
+
function terminologyOf(bundle) {
|
|
348
|
+
const comp = compositionOf(bundle);
|
|
349
|
+
if (!comp) {
|
|
350
|
+
return { composition: [], sections: [] };
|
|
351
|
+
}
|
|
352
|
+
const result = {
|
|
353
|
+
composition: conceptsOf(comp, { skip: ['section'] }),
|
|
354
|
+
sections: []
|
|
355
|
+
};
|
|
356
|
+
// sections, flattened (a nested section is its own entry in the list, after its parent)
|
|
357
|
+
const flat = [];
|
|
358
|
+
const collect = (secs, depth) => {
|
|
359
|
+
for (const s of Array.isArray(secs) ? secs : []) {
|
|
360
|
+
if (isObject(s)) {
|
|
361
|
+
flat.push({ section: s, depth, order: flat.length });
|
|
362
|
+
collect(s.section, depth + 1);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
collect(comp.section, 0);
|
|
367
|
+
const ranked = flat.map(f => ({ ...f, rank: sectionRank(f.section) }));
|
|
368
|
+
ranked.sort((a, b) => {
|
|
369
|
+
const ra = a.rank < 0 ? Number.MAX_SAFE_INTEGER : a.rank;
|
|
370
|
+
const rb = b.rank < 0 ? Number.MAX_SAFE_INTEGER : b.rank;
|
|
371
|
+
return ra - rb || a.order - b.order;
|
|
372
|
+
});
|
|
373
|
+
const fullUrls = bundleIndex(bundle).fullUrlOf;
|
|
374
|
+
const fullUrlOf = (resource) => fullUrls.get(resource) || null;
|
|
375
|
+
for (const { section, rank } of ranked) {
|
|
376
|
+
const title = rank >= 0 ? SECTION_ORDER[rank][1] : (typeof section.title === 'string' && section.title ? section.title : '(untitled section)');
|
|
377
|
+
const entries = [];
|
|
378
|
+
for (const ref of Array.isArray(section.entry) ? section.entry : []) {
|
|
379
|
+
const reference = typeof ref?.reference === 'string' ? ref.reference : '';
|
|
380
|
+
const resource = resolveInBundle(bundle, reference);
|
|
381
|
+
if (!resource) {
|
|
382
|
+
entries.push({ reference, label: reference || '(no reference)', resolved: false, concepts: [] });
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
entries.push({ reference, label: resourceLabel(resource, fullUrlOf(resource)), resolved: true, concepts: conceptsOf(resource) });
|
|
386
|
+
}
|
|
387
|
+
result.sections.push({ title, known: rank >= 0, code: section.code, entries });
|
|
388
|
+
}
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
module.exports = {
|
|
393
|
+
terminologyOf, conceptsOf, systemName, describeVersion, sctEdition, linkForSystem, linkForCode, isExternalSystem, isIgnored,
|
|
394
|
+
sectionRank, SECTION_ORDER, IGNORED, SYSTEM_NAMES
|
|
395
|
+
};
|
package/ips/tx-client.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
|
|
3
|
+
//
|
|
4
|
+
// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
|
|
5
|
+
//
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The terminology server the /ips module uses: for links to code systems now, and for
|
|
9
|
+
* the queries the analysis will make.
|
|
10
|
+
*
|
|
11
|
+
* It is named by modules.ips.txServer, which is either
|
|
12
|
+
*
|
|
13
|
+
* - an http(s) URL: an external server, reached over the network; or
|
|
14
|
+
* - a path on this server, such as /tx/r4: the tx module's own endpoint. Requests to it
|
|
15
|
+
* never leave the process - they are dispatched straight into the Express app, so
|
|
16
|
+
* they go through the same middleware and routes an HTTP request would, without a
|
|
17
|
+
* socket.
|
|
18
|
+
*
|
|
19
|
+
* Either way, the client speaks FHIR over the server's REST interface, so the analysis
|
|
20
|
+
* code doesn't care which it has.
|
|
21
|
+
*
|
|
22
|
+
* @module ips/tx-client
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const http = require('http');
|
|
26
|
+
const { Duplex } = require('stream');
|
|
27
|
+
|
|
28
|
+
const FHIR_JSON = 'application/fhir+json';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Send a request into an Express app without a network: a real http.IncomingMessage and
|
|
32
|
+
* http.ServerResponse, with no socket, so everything Express and its middleware rely on
|
|
33
|
+
* (headers, the body stream, res.end) is there.
|
|
34
|
+
*
|
|
35
|
+
* @param {Function} app - the Express app
|
|
36
|
+
* @param {{method: string, url: string, headers?: Object, body?: string}} req
|
|
37
|
+
* @returns {Promise<{status: number, headers: Object, body: string}>}
|
|
38
|
+
*/
|
|
39
|
+
function dispatch(app, { method, url, headers = {}, body }) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const req = new http.IncomingMessage(null);
|
|
42
|
+
req.method = method;
|
|
43
|
+
req.url = url;
|
|
44
|
+
req.headers = Object.fromEntries(Object.entries({ host: 'localhost', ...headers }).map(([k, v]) => [k.toLowerCase(), String(v)]));
|
|
45
|
+
// enough of a socket for Express, on-finished, the body parsers and the message's own
|
|
46
|
+
// teardown to be happy: a stream that goes nowhere
|
|
47
|
+
req.socket = new Duplex({ read() {}, write(chunk, encoding, cb) { cb(); } });
|
|
48
|
+
req.socket.remoteAddress = '127.0.0.1';
|
|
49
|
+
req.socket.encrypted = false;
|
|
50
|
+
req.connection = req.socket;
|
|
51
|
+
if (body !== undefined) {
|
|
52
|
+
const buf = Buffer.from(body, 'utf8');
|
|
53
|
+
req.headers['content-length'] = String(buf.length);
|
|
54
|
+
// the body is the stream Express's body parsers read
|
|
55
|
+
req.push(buf);
|
|
56
|
+
}
|
|
57
|
+
// a real request is marked complete by the HTTP parser once its body is in; the body
|
|
58
|
+
// parsers (on-finished) look at that to know a later parser needn't read again
|
|
59
|
+
req.on('end', () => {
|
|
60
|
+
req.complete = true;
|
|
61
|
+
});
|
|
62
|
+
req.push(null);
|
|
63
|
+
const res = new http.ServerResponse(req);
|
|
64
|
+
const chunks = [];
|
|
65
|
+
res.write = (chunk, encoding, cb) => {
|
|
66
|
+
if (chunk) {
|
|
67
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === 'string' ? encoding : 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
if (typeof encoding === 'function') {
|
|
70
|
+
encoding();
|
|
71
|
+
} else if (cb) {
|
|
72
|
+
cb();
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
res.end = (chunk, encoding, cb) => {
|
|
77
|
+
if (typeof chunk === 'function') {
|
|
78
|
+
cb = chunk;
|
|
79
|
+
chunk = undefined;
|
|
80
|
+
} else if (typeof encoding === 'function') {
|
|
81
|
+
cb = encoding;
|
|
82
|
+
encoding = undefined;
|
|
83
|
+
}
|
|
84
|
+
if (chunk) {
|
|
85
|
+
res.write(chunk, encoding);
|
|
86
|
+
}
|
|
87
|
+
res.finished = true;
|
|
88
|
+
res.writableEnded = true;
|
|
89
|
+
if (cb) {
|
|
90
|
+
cb();
|
|
91
|
+
}
|
|
92
|
+
resolve({ status: res.statusCode, headers: res.getHeaders(), body: Buffer.concat(chunks).toString('utf8') });
|
|
93
|
+
return res;
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
app(req, res);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
reject(e);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class TxClient {
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} server - modules.ips.txServer: an http(s) URL, or a path on this server
|
|
106
|
+
* @param {Object} [options]
|
|
107
|
+
* @param {Function} [options.app] - the Express app, for a path on this server
|
|
108
|
+
* @param {number} [options.timeoutMs] - how long to wait for an answer (default 30s)
|
|
109
|
+
* @param {Object} [options.log] - a logger, for what callers shouldn't show
|
|
110
|
+
*/
|
|
111
|
+
constructor(server, options = {}) {
|
|
112
|
+
if (typeof server !== 'string' || server.trim() === '') {
|
|
113
|
+
throw new Error('A terminology server is required: an http(s) URL, or a path on this server such as /tx/r4');
|
|
114
|
+
}
|
|
115
|
+
const s = server.trim().replace(/\/+$/, '');
|
|
116
|
+
if (/^https?:\/\//i.test(s)) {
|
|
117
|
+
this.internal = false;
|
|
118
|
+
this.base = s;
|
|
119
|
+
} else if (s.startsWith('/')) {
|
|
120
|
+
if (typeof options.app !== 'function') {
|
|
121
|
+
throw new Error(`The terminology server '${server}' is a path on this server, which needs the Express app to route to`);
|
|
122
|
+
}
|
|
123
|
+
this.internal = true;
|
|
124
|
+
this.base = s;
|
|
125
|
+
this.app = options.app;
|
|
126
|
+
} else {
|
|
127
|
+
throw new Error(`The terminology server must be an http(s) URL or a path on this server, not '${server}'`);
|
|
128
|
+
}
|
|
129
|
+
this.timeoutMs = options.timeoutMs || 30000;
|
|
130
|
+
this.log = options.log || null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Where the server is, for links and messages: a URL, or a path on this server. */
|
|
134
|
+
get url() {
|
|
135
|
+
return this.base;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The address of a code system on the server: its search page / search result. */
|
|
139
|
+
codeSystemLink(system) {
|
|
140
|
+
return `${this.base}/CodeSystem?url=${encodeURIComponent(system)}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A FHIR request to the server.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} method - GET or POST
|
|
147
|
+
* @param {string} path - relative to the server, e.g. /CodeSystem/$validate-code
|
|
148
|
+
* @param {Object} [options]
|
|
149
|
+
* @param {Object} [options.query] - query parameters
|
|
150
|
+
* @param {Object} [options.body] - a resource (POST)
|
|
151
|
+
* @returns {Promise<{status: number, resource: Object|null, text: string}>} the parsed
|
|
152
|
+
* JSON when there is some
|
|
153
|
+
*/
|
|
154
|
+
async request(method, path, options = {}) {
|
|
155
|
+
const qs = options.query ? new URLSearchParams(Object.entries(options.query).filter(([, v]) => v !== undefined && v !== null)).toString() : '';
|
|
156
|
+
const target = `${this.base}${path}${qs ? '?' + qs : ''}`;
|
|
157
|
+
const headers = { Accept: FHIR_JSON };
|
|
158
|
+
let body;
|
|
159
|
+
if (options.body !== undefined) {
|
|
160
|
+
headers['Content-Type'] = FHIR_JSON;
|
|
161
|
+
body = JSON.stringify(options.body);
|
|
162
|
+
}
|
|
163
|
+
let status;
|
|
164
|
+
let text;
|
|
165
|
+
if (this.internal) {
|
|
166
|
+
// a handler that never answers must not hang the caller (the pipeline runs one
|
|
167
|
+
// document at a time, and would stop for good)
|
|
168
|
+
let timer;
|
|
169
|
+
const r = await Promise.race([
|
|
170
|
+
dispatch(this.app, { method, url: target, headers, body }),
|
|
171
|
+
new Promise((_, reject) => {
|
|
172
|
+
timer = setTimeout(() => reject(new Error(`No answer from ${target} within ${this.timeoutMs}ms`)), this.timeoutMs);
|
|
173
|
+
})
|
|
174
|
+
]).finally(() => clearTimeout(timer));
|
|
175
|
+
status = r.status;
|
|
176
|
+
text = r.body;
|
|
177
|
+
} else {
|
|
178
|
+
const r = await fetch(target, { method, headers, body, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
179
|
+
status = r.status;
|
|
180
|
+
text = await r.text();
|
|
181
|
+
}
|
|
182
|
+
let resource = null;
|
|
183
|
+
if (text) {
|
|
184
|
+
try {
|
|
185
|
+
resource = JSON.parse(text);
|
|
186
|
+
} catch {
|
|
187
|
+
resource = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { status, resource, text };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get(path, query) {
|
|
194
|
+
return this.request('GET', path, { query });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
post(path, body) {
|
|
198
|
+
return this.request('POST', path, { body });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { TxClient, dispatch };
|