fhirsmith 0.13.4 → 0.14.1
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 +61 -1
- package/README.md +16 -3
- package/config-template.json +81 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +399 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +241 -56
- package/registry/registry.js +3 -1
- package/server.js +57 -15
- package/stats.js +19 -13
- package/testing/readme.md +144 -0
- package/testing/render.js +723 -0
- package/testing/search.js +378 -0
- package/testing/store.js +410 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +760 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +19 -69
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/editions.js +70 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +2 -2
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +192 -21
- package/tx/workers/validate.js +70 -187
- package/tx/workers/worker.js +12 -35
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
package/tx/workers/closure.js
CHANGED
|
@@ -1,27 +1,497 @@
|
|
|
1
1
|
//
|
|
2
|
-
// Closure Worker - Handles
|
|
2
|
+
// Closure Worker - Handles the $closure operation
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// POST /ConceptMap/$closure
|
|
4
|
+
// POST [base]/$closure
|
|
6
5
|
//
|
|
6
|
+
// $closure is defined on ConceptMap (OperationDefinition ConceptMap-closure) but is a
|
|
7
|
+
// SYSTEM-level operation: system=true, type=false, instance=false in R3, R4 and R5.
|
|
8
|
+
// So it lives at [base]/$closure, not [base]/ConceptMap/$closure (issue #100).
|
|
9
|
+
//
|
|
10
|
+
// A closure table is state held on the server between calls, and between client
|
|
11
|
+
// sessions, so it only exists when the administrator has turned it on and said where
|
|
12
|
+
// it is kept (modules.tx.closure - see ClosureStore). Without that, every request gets
|
|
13
|
+
// a not-supported OperationOutcome.
|
|
14
|
+
//
|
|
15
|
+
// The calls:
|
|
16
|
+
// name (+ reset, + configuration) initialise the table. It is an error if the table
|
|
17
|
+
// already exists, unless reset = true, which wipes it
|
|
18
|
+
// name + concept(s) add concepts; returns the entries they produce
|
|
19
|
+
// name + version resync; returns every entry added after version
|
|
20
|
+
//
|
|
21
|
+
// The configuration is fixed when the table is initialised, and replayed on every later
|
|
22
|
+
// call: tx-resource (the code systems it is built over, which the client does not send
|
|
23
|
+
// again), useSupplement, system-version, check-system-version, force-system-version.
|
|
24
|
+
//
|
|
25
|
+
// Closure is stable across sessions, so it never uses a client's terminology cache: the
|
|
26
|
+
// X-Cache-Id header is ignored here.
|
|
27
|
+
//
|
|
28
|
+
// Access: the table name is the only thing that identifies a table, so anyone who knows a
|
|
29
|
+
// name can read, add to, or reset that table. On a public server, require UUID names
|
|
30
|
+
// (requireUuidNames) so that a name is effectively a secret held by the client that made it.
|
|
31
|
+
//
|
|
32
|
+
// Cost: everything a client can make this do is bounded (see LIMITS) - the tables, the
|
|
33
|
+
// concepts and entries in a table, what one call can add, what a table's configuration
|
|
34
|
+
// can store, and how many calls can queue for one table and for how long. The work itself
|
|
35
|
+
// yields (checkAndYield) and runs against the operation's compute deadline; the storage
|
|
36
|
+
// is synchronous, so reads that can be large are paged and writes are capped.
|
|
37
|
+
//
|
|
38
|
+
|
|
39
|
+
const { TerminologyWorker, Unknown_Code_in_VersionSCT, SCTVersion } = require('./worker');
|
|
40
|
+
const { TxParameters } = require('../params');
|
|
41
|
+
const { Issue, OperationOutcome, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
|
|
42
|
+
const { debugLog } = require('../operation-context');
|
|
43
|
+
const { NARROWER, EQUIVALENT } = require('../closure/closure-store');
|
|
44
|
+
const { VersionUtilities } = require('../../library/version-utilities');
|
|
45
|
+
|
|
46
|
+
// parameters that configure a table, and so are only accepted when it is initialised
|
|
47
|
+
const CONFIG_PARAMS = ['tx-resource', 'useSupplement', 'system-version', 'check-system-version', 'force-system-version'];
|
|
48
|
+
|
|
49
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
50
|
+
|
|
51
|
+
// Defaults for modules.tx.closure.<name>. Absent = the default; 0 = no limit.
|
|
52
|
+
const LIMITS = {
|
|
53
|
+
maxTables: 1000,
|
|
54
|
+
maxConceptsPerTable: 10000,
|
|
55
|
+
maxConceptsPerRequest: 1000,
|
|
56
|
+
maxEntriesPerTable: 250000,
|
|
57
|
+
maxEntriesPerRequest: 50000,
|
|
58
|
+
maxConfigSize: 5 * 1024 * 1024, // bytes of stored configuration (mostly tx-resource)
|
|
59
|
+
maxQueue: 10, // calls queued on one table, counting the running one
|
|
60
|
+
lockTimeout: 60 // seconds a call will wait for its table
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const RESYNC_PAGE = 2000; // rows read from the store at a time
|
|
64
|
+
|
|
65
|
+
class ClosureWorker extends TerminologyWorker {
|
|
66
|
+
/**
|
|
67
|
+
* @param {OperationContext} opContext - Operation context
|
|
68
|
+
* @param {Logger} log - Logger instance
|
|
69
|
+
* @param {Provider} provider - Provider for code systems and resources
|
|
70
|
+
* @param {LanguageDefinitions} languages - Language definitions
|
|
71
|
+
* @param {I18nSupport} i18n - Internationalization support
|
|
72
|
+
* @param {ClosureStore|null} store - where closure tables are kept; null = not supported
|
|
73
|
+
* @param {Object} [config] - modules.tx.closure
|
|
74
|
+
*/
|
|
75
|
+
constructor(opContext, log, provider, languages, i18n, store = null, config = {}) {
|
|
76
|
+
super(opContext, log, provider, languages, i18n);
|
|
77
|
+
this.store = store;
|
|
78
|
+
this.closureConfig = config || {};
|
|
79
|
+
}
|
|
7
80
|
|
|
8
|
-
|
|
81
|
+
opName() {
|
|
82
|
+
return 'closure';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Not a value-set operation; the base class requires this to be implemented.
|
|
86
|
+
vsHandle() {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The configured value of a limit, or its default. 0 means no limit (Infinity).
|
|
92
|
+
*/
|
|
93
|
+
limit(name) {
|
|
94
|
+
const v = this.closureConfig[name];
|
|
95
|
+
const n = (v === undefined || v === null) ? LIMITS[name] : Number(v);
|
|
96
|
+
return (!Number.isFinite(n) || n <= 0) ? Infinity : n;
|
|
97
|
+
}
|
|
9
98
|
|
|
10
|
-
class ClosureWorker {
|
|
11
99
|
/**
|
|
12
|
-
*
|
|
13
|
-
* @param {express.Request} req - Express request
|
|
100
|
+
* Express entry point for [base]/$closure (GET and POST).
|
|
101
|
+
* @param {express.Request} req - Express request
|
|
14
102
|
* @param {express.Response} res - Express response
|
|
15
|
-
* @param {Object} log - Logger instance
|
|
16
103
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
104
|
+
async handle(req, res) {
|
|
105
|
+
try {
|
|
106
|
+
if (!this.store) {
|
|
107
|
+
return res.status(501).json(buildOperationOutcome('error', 'not-supported',
|
|
108
|
+
this.i18n.translate('CLOSURE_NOT_SUPPORTED', this.opContext.langs, []), 'not-supported'));
|
|
109
|
+
}
|
|
110
|
+
// $closure changes state on the server (affectsState = true), so GET isn't allowed
|
|
111
|
+
if (req.method !== 'POST') {
|
|
112
|
+
throw this.closureIssue('invalid', 'CLOSURE_POST_ONLY', [], 'invalid-data', 405);
|
|
113
|
+
}
|
|
114
|
+
const params = this.buildParameters(req);
|
|
115
|
+
const fhirVersion = req.txEndpoint ? req.txEndpoint.fhirVersion : '5.0';
|
|
116
|
+
const cm = await this.closure(params, fhirVersion);
|
|
117
|
+
return res.status(200).json(cm);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
this.log.error(error);
|
|
120
|
+
debugLog(error);
|
|
121
|
+
req.logInfo = this.usedSources.join('|') + ' - error' + (error.msgId ? ' ' + error.msgId : '');
|
|
122
|
+
if (error instanceof Issue) {
|
|
123
|
+
const oo = new OperationOutcome();
|
|
124
|
+
oo.addIssue(error);
|
|
125
|
+
return res.status(error.statusCode || 500).json(oo.jsonObj);
|
|
126
|
+
}
|
|
127
|
+
return res.status(error.statusCode || 500).json(outcomeFromError(error));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Do the operation. Everything after the request checks happens with the table locked,
|
|
133
|
+
* so two calls on the same table can't interleave - each one reads the table, works out
|
|
134
|
+
* what's new (which awaits, and yields), and writes, before the next starts.
|
|
135
|
+
*
|
|
136
|
+
* @param {Object} params - Parameters resource
|
|
137
|
+
* @param {string} fhirVersion - the endpoint's FHIR version, for the response
|
|
138
|
+
* @returns {Object} ConceptMap
|
|
139
|
+
*/
|
|
140
|
+
async closure(params, fhirVersion) {
|
|
141
|
+
// never the client's terminology cache - see the header comment
|
|
142
|
+
this.opContext.cacheId = null;
|
|
143
|
+
const list = (params.parameter || []).filter(p => p.name !== 'cache-id');
|
|
144
|
+
|
|
145
|
+
const name = this.readName(list);
|
|
146
|
+
const concepts = [];
|
|
147
|
+
const config = [];
|
|
148
|
+
let versionParam = null;
|
|
149
|
+
let reset = false;
|
|
150
|
+
const ignored = [];
|
|
151
|
+
for (const p of list) {
|
|
152
|
+
if (p.name === 'concept') {
|
|
153
|
+
concepts.push(p);
|
|
154
|
+
} else if (CONFIG_PARAMS.includes(p.name)) {
|
|
155
|
+
config.push(p);
|
|
156
|
+
} else if (p.name === 'version') {
|
|
157
|
+
versionParam = p;
|
|
158
|
+
} else if (p.name === 'reset') {
|
|
159
|
+
reset = p.valueBoolean === true || p.valueString === 'true';
|
|
160
|
+
} else if (p.name !== 'name') {
|
|
161
|
+
// Ignored, as everywhere else in the server: a request carries parameters that
|
|
162
|
+
// are not for the operation in hand. The tx-ecosystem runner, for one, adds a
|
|
163
|
+
// 'uuid' to every request from its default profile
|
|
164
|
+
ignored.push(p.name);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (ignored.length > 0) {
|
|
168
|
+
this.log.debug(`$closure: ignored parameter(s) ${ignored.map(safeText).join(', ')}`);
|
|
169
|
+
}
|
|
170
|
+
if (concepts.length > 0 && versionParam) {
|
|
171
|
+
throw this.closureIssue('invalid', 'CLOSURE_CONCEPT_AND_VERSION', [], 'invalid-data', 400);
|
|
172
|
+
}
|
|
173
|
+
const maxRequest = this.limit('maxConceptsPerRequest');
|
|
174
|
+
if (concepts.length > maxRequest) {
|
|
175
|
+
throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_REQUEST_CONCEPTS', [String(maxRequest)], 'too-costly', 422);
|
|
176
|
+
}
|
|
177
|
+
// checked here, before anything waits for the table
|
|
178
|
+
let configSize = 0;
|
|
179
|
+
for (const p of config) {
|
|
180
|
+
configSize += JSON.stringify(p).length;
|
|
181
|
+
}
|
|
182
|
+
const maxConfig = this.limit('maxConfigSize');
|
|
183
|
+
if (configSize > maxConfig) {
|
|
184
|
+
throw this.closureIssue('too-costly', 'CLOSURE_CONFIG_TOO_LARGE', [String(maxConfig)], 'too-costly', 422);
|
|
185
|
+
}
|
|
19
186
|
|
|
20
|
-
this.
|
|
187
|
+
return await this.lockTable(name, async () => {
|
|
188
|
+
const initialise = reset || (concepts.length === 0 && !versionParam);
|
|
189
|
+
let table = this.store.getTable(name);
|
|
21
190
|
|
|
22
|
-
|
|
23
|
-
|
|
191
|
+
if (initialise) {
|
|
192
|
+
if (versionParam) {
|
|
193
|
+
throw this.closureIssue('invalid', 'CLOSURE_RESET_AND_VERSION', [], 'invalid-data', 400);
|
|
194
|
+
}
|
|
195
|
+
if (table && !reset) {
|
|
196
|
+
throw this.closureIssue('duplicate', 'CLOSURE_TABLE_EXISTS', [name], 'business-rule', 409);
|
|
197
|
+
}
|
|
198
|
+
const max = this.limit('maxTables');
|
|
199
|
+
if (!table && this.store.tableCount() >= max) {
|
|
200
|
+
throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_TABLES', [String(max)], 'too-costly', 422);
|
|
201
|
+
}
|
|
202
|
+
table = this.store.createTable(name, config);
|
|
203
|
+
this.log.info(`closure table '${name}' ${reset ? 'reset' : 'created'}`);
|
|
204
|
+
if (concepts.length === 0) {
|
|
205
|
+
return await this.buildConceptMap(name, table.version, [], fhirVersion);
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
if (!table) {
|
|
209
|
+
throw this.closureIssue('not-found', 'CLOSURE_TABLE_UNKNOWN', [name], 'not-found', 404);
|
|
210
|
+
}
|
|
211
|
+
if (config.length > 0) {
|
|
212
|
+
throw this.closureIssue('invalid', 'CLOSURE_CONFIG_NOT_ALLOWED', [config[0].name], 'invalid-data', 400);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (versionParam) {
|
|
217
|
+
return await this.resync(table, name, versionParam, fhirVersion);
|
|
218
|
+
}
|
|
219
|
+
return await this.add(table, name, concepts, fhirVersion);
|
|
220
|
+
});
|
|
24
221
|
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Take the table's lock, with a bounded queue and a bounded wait. The wait goes through
|
|
225
|
+
* OperationContext.waitFor: it isn't charged to the compute deadline (the call isn't
|
|
226
|
+
* computing), and a client that has gone away stops waiting.
|
|
227
|
+
*/
|
|
228
|
+
async lockTable(name, fn) {
|
|
229
|
+
const maxQueue = this.limit('maxQueue');
|
|
230
|
+
const timeout = this.limit('lockTimeout');
|
|
231
|
+
try {
|
|
232
|
+
return await this.store.withLock(name, fn, {
|
|
233
|
+
maxQueue: maxQueue === Infinity ? 0 : maxQueue,
|
|
234
|
+
timeoutMs: timeout === Infinity ? 0 : timeout * 1000,
|
|
235
|
+
waitFor: p => this.opContext.waitFor(p, 'closure-lock')
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error.closureBusy) {
|
|
239
|
+
throw this.closureIssue('too-costly', 'CLOSURE_TABLE_BUSY', [name], 'too-costly', 429);
|
|
240
|
+
}
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The table name: required, no whitespace or control characters, at most 64 characters
|
|
247
|
+
* - and a UUID, if the administrator has said so (tx.fhir.org does, so names chosen by
|
|
248
|
+
* different clients can't collide, and can't be guessed).
|
|
249
|
+
*/
|
|
250
|
+
readName(list) {
|
|
251
|
+
const p = list.find(x => x.name === 'name');
|
|
252
|
+
const name = p ? this.getParameterValue(p) : null;
|
|
253
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
254
|
+
throw this.closureIssue('required', 'CLOSURE_NAME_REQUIRED', [], 'invalid-data', 400);
|
|
255
|
+
}
|
|
256
|
+
if (/[\s\p{Cc}]/u.test(name) || name.length > 64) {
|
|
257
|
+
throw this.closureIssue('invalid', 'CLOSURE_NAME_INVALID', [safeText(name)], 'invalid-data', 400);
|
|
258
|
+
}
|
|
259
|
+
if (this.closureConfig.requireUuidNames && !UUID.test(name)) {
|
|
260
|
+
throw this.closureIssue('invalid', 'CLOSURE_NAME_NOT_UUID', [name], 'invalid-data', 400);
|
|
261
|
+
}
|
|
262
|
+
return name;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Resync: every entry added after the nominated version, at the table's current version.
|
|
267
|
+
* The table can be large, so it is read a page at a time, yielding in between.
|
|
268
|
+
*/
|
|
269
|
+
async resync(table, name, versionParam, fhirVersion) {
|
|
270
|
+
const raw = this.getParameterValue(versionParam);
|
|
271
|
+
const v = typeof raw === 'number' ? raw : (/^\d{1,15}$/.test(String(raw)) ? parseInt(raw, 10) : NaN);
|
|
272
|
+
if (!Number.isInteger(v) || v < 0 || v > table.version) {
|
|
273
|
+
throw this.closureIssue('invalid', 'CLOSURE_VERSION_INVALID', [safeText(raw), name, String(table.version)], 'invalid-data', 400);
|
|
274
|
+
}
|
|
275
|
+
this.store.touch(table.id);
|
|
276
|
+
const entries = [];
|
|
277
|
+
let after = 0;
|
|
278
|
+
for (;;) {
|
|
279
|
+
await this.checkAndYield('closure-resync');
|
|
280
|
+
const page = this.store.entriesPage(table.id, v, after, RESYNC_PAGE);
|
|
281
|
+
for (const r of page) {
|
|
282
|
+
entries.push({
|
|
283
|
+
source: { system: r.source_system, code: r.source_code, display: r.source_display },
|
|
284
|
+
target: { system: r.target_system, code: r.target_code, display: r.target_display },
|
|
285
|
+
relationship: r.relationship
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (page.length < RESYNC_PAGE) {
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
after = page[page.length - 1].rowid;
|
|
292
|
+
}
|
|
293
|
+
return await this.buildConceptMap(name, table.version, entries, fhirVersion);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Add concepts. Each new concept is compared with every concept of the same code system
|
|
298
|
+
* already in the table (and with the ones added before it in this call), in both
|
|
299
|
+
* directions. Nothing is written until every concept has been checked: an unknown code
|
|
300
|
+
* system or code, or going over a limit, fails the whole call.
|
|
301
|
+
*/
|
|
302
|
+
async add(table, name, conceptParams, fhirVersion) {
|
|
303
|
+
// replay the table's configuration: its code systems, and its version rules
|
|
304
|
+
const stored = { resourceType: 'Parameters', parameter: this.store.getParams(table.id) };
|
|
305
|
+
this.setupAdditionalResources(stored);
|
|
306
|
+
const txp = new TxParameters(this.opContext.i18n.languageDefinitions, this.opContext.i18n);
|
|
307
|
+
txp.readParams(stored);
|
|
308
|
+
|
|
309
|
+
const existing = this.store.getConcepts(table.id);
|
|
310
|
+
const known = new Set();
|
|
311
|
+
for (const c of existing) {
|
|
312
|
+
known.add(c.system + '|' + c.code);
|
|
313
|
+
}
|
|
314
|
+
const providers = new Map();
|
|
315
|
+
const fresh = [];
|
|
316
|
+
|
|
317
|
+
for (const p of conceptParams) {
|
|
318
|
+
await this.checkAndYield('closure-add');
|
|
319
|
+
const coding = p.valueCoding;
|
|
320
|
+
if (!coding || typeof coding.system !== 'string' || typeof coding.code !== 'string' || !coding.system || !coding.code) {
|
|
321
|
+
throw this.closureIssue('invalid', 'CLOSURE_CONCEPT_INVALID', [], 'invalid-data', 400);
|
|
322
|
+
}
|
|
323
|
+
let cs = providers.get(coding.system);
|
|
324
|
+
if (!cs) {
|
|
325
|
+
cs = await this.findCodeSystem(coding.system, coding.version || '', txp, ['complete'], null, false, true);
|
|
326
|
+
this.seeSourceProvider(cs, coding.system);
|
|
327
|
+
providers.set(coding.system, cs);
|
|
328
|
+
}
|
|
329
|
+
const loc = await cs.locate(coding.code);
|
|
330
|
+
if (!loc || !loc.context) {
|
|
331
|
+
throw this.unknownCodeIssue(cs, coding.code, loc ? loc.message : null, txp);
|
|
332
|
+
}
|
|
333
|
+
// the code system's own spelling of the code, so a case-insensitive code system
|
|
334
|
+
// doesn't end up with the same concept in the table twice
|
|
335
|
+
const code = (await cs.code(loc.context)) || coding.code;
|
|
336
|
+
const key = coding.system + '|' + code;
|
|
337
|
+
if (known.has(key)) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
known.add(key);
|
|
341
|
+
// ref: how the store refers to it until it has an id (see ClosureStore.recordAdd)
|
|
342
|
+
fresh.push({ key, ref: key, system: coding.system, code, display: await cs.display(loc.context), cs });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const maxConcepts = this.limit('maxConceptsPerTable');
|
|
346
|
+
if (fresh.length > 0 && existing.length + fresh.length > maxConcepts) {
|
|
347
|
+
throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_CONCEPTS', [name, String(maxConcepts)], 'too-costly', 422);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// work out the entries: each new concept against everything before it. The number of
|
|
351
|
+
// entries is capped as it grows - a client-supplied code system can be shaped so that
|
|
352
|
+
// nearly every pair is related - so the write below is always bounded
|
|
353
|
+
const maxEntries = Math.min(this.limit('maxEntriesPerRequest'),
|
|
354
|
+
this.limit('maxEntriesPerTable') - (fresh.length > 0 ? this.store.entryCount(table.id) : 0));
|
|
355
|
+
const entries = [];
|
|
356
|
+
const out = [];
|
|
357
|
+
const before = existing.map(c => ({ ref: c.id, system: c.system, code: c.code, display: c.display }));
|
|
358
|
+
for (const n of fresh) {
|
|
359
|
+
for (const o of before) {
|
|
360
|
+
if (o.system !== n.system) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
await this.checkAndYield('closure-subsumes');
|
|
364
|
+
const outcome = await n.cs.subsumesTest(n.code, o.code);
|
|
365
|
+
let source, target, relationship;
|
|
366
|
+
if (outcome === 'subsumes') { // the new concept is the broader one
|
|
367
|
+
source = o; target = n; relationship = NARROWER;
|
|
368
|
+
} else if (outcome === 'subsumed-by') { // the new concept is the narrower one
|
|
369
|
+
source = n; target = o; relationship = NARROWER;
|
|
370
|
+
} else if (outcome === 'equivalent') {
|
|
371
|
+
source = n; target = o; relationship = EQUIVALENT;
|
|
372
|
+
} else {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (entries.length >= maxEntries) {
|
|
376
|
+
throw this.closureIssue('too-costly', 'CLOSURE_TOO_MANY_ENTRIES', [name], 'too-costly', 422);
|
|
377
|
+
}
|
|
378
|
+
entries.push({ source: source.ref, target: target.ref, relationship });
|
|
379
|
+
out.push({ source, target, relationship });
|
|
380
|
+
}
|
|
381
|
+
before.push(n);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const version = this.store.recordAdd(table, fresh, entries);
|
|
385
|
+
if (fresh.length > 0) {
|
|
386
|
+
this.log.info(`closure table '${name}': added ${fresh.length} concept(s), ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}, now version ${version}`);
|
|
387
|
+
}
|
|
388
|
+
return await this.buildConceptMap(name, version, out, fhirVersion);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The response: a ConceptMap of the entries, one group per source/target system, sorted
|
|
393
|
+
* so the same table always reads the same way. For R3/R4 the relationship is also given
|
|
394
|
+
* as the equivalence the R3/R4 OperationDefinition names ('subsumes' - the target
|
|
395
|
+
* subsumes the source), which the version transform then keeps in preference to the one
|
|
396
|
+
* it would work out itself ('wider').
|
|
397
|
+
*
|
|
398
|
+
* A resync can hand this a whole table, so it yields as it goes, and never sorts the
|
|
399
|
+
* entries as one list: elements are sorted within their group (at most one per concept)
|
|
400
|
+
* and targets within their element.
|
|
401
|
+
*/
|
|
402
|
+
async buildConceptMap(name, version, entries, fhirVersion) {
|
|
403
|
+
const legacy = VersionUtilities.isR3Ver(fhirVersion) || VersionUtilities.isR4Ver(fhirVersion);
|
|
404
|
+
const cm = {
|
|
405
|
+
resourceType: 'ConceptMap',
|
|
406
|
+
version: String(version),
|
|
407
|
+
title: `Closure table ${name}`,
|
|
408
|
+
status: 'active',
|
|
409
|
+
experimental: true,
|
|
410
|
+
date: new Date().toISOString()
|
|
411
|
+
};
|
|
412
|
+
const groups = new Map(); // source system -> target system -> source code -> element
|
|
413
|
+
for (const e of entries) {
|
|
414
|
+
await this.checkAndYield('closure-response');
|
|
415
|
+
let bySource = groups.get(e.source.system);
|
|
416
|
+
if (!bySource) {
|
|
417
|
+
bySource = new Map();
|
|
418
|
+
groups.set(e.source.system, bySource);
|
|
419
|
+
}
|
|
420
|
+
let g = bySource.get(e.target.system);
|
|
421
|
+
if (!g) {
|
|
422
|
+
g = new Map();
|
|
423
|
+
bySource.set(e.target.system, g);
|
|
424
|
+
}
|
|
425
|
+
let el = g.get(e.source.code);
|
|
426
|
+
if (!el) {
|
|
427
|
+
el = { code: e.source.code };
|
|
428
|
+
if (e.source.display) {
|
|
429
|
+
el.display = e.source.display;
|
|
430
|
+
}
|
|
431
|
+
el.target = [];
|
|
432
|
+
g.set(e.source.code, el);
|
|
433
|
+
}
|
|
434
|
+
const t = { code: e.target.code };
|
|
435
|
+
if (e.target.display) {
|
|
436
|
+
t.display = e.target.display;
|
|
437
|
+
}
|
|
438
|
+
t.relationship = e.relationship;
|
|
439
|
+
if (legacy) {
|
|
440
|
+
t.equivalence = e.relationship === EQUIVALENT ? 'equal' : 'subsumes';
|
|
441
|
+
}
|
|
442
|
+
el.target.push(t);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const byKey = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
446
|
+
const group = [];
|
|
447
|
+
for (const src of [...groups.keys()].sort(byKey)) {
|
|
448
|
+
const bySource = groups.get(src);
|
|
449
|
+
for (const tgt of [...bySource.keys()].sort(byKey)) {
|
|
450
|
+
const g = bySource.get(tgt);
|
|
451
|
+
const element = [];
|
|
452
|
+
for (const code of [...g.keys()].sort(byKey)) {
|
|
453
|
+
await this.checkAndYield('closure-response');
|
|
454
|
+
const el = g.get(code);
|
|
455
|
+
el.target.sort((a, b) => byKey(a.code, b.code));
|
|
456
|
+
element.push(el);
|
|
457
|
+
}
|
|
458
|
+
group.push({ source: src, target: tgt, element });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (group.length > 0) {
|
|
462
|
+
cm.group = group;
|
|
463
|
+
}
|
|
464
|
+
return cm;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
closureIssue(cause, msgId, params, txIssueType, status) {
|
|
468
|
+
return new Issue('error', cause, null, msgId, this.i18n.translate(msgId, this.opContext.langs, params), txIssueType, status);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* An unknown code - the same message id and tx-issue-type as $validate-code and $subsumes.
|
|
473
|
+
*/
|
|
474
|
+
unknownCodeIssue(cs, code, message, txp) {
|
|
475
|
+
const system = cs.system();
|
|
476
|
+
const version = cs.version();
|
|
477
|
+
const msgId = Unknown_Code_in_VersionSCT(system, version);
|
|
478
|
+
const msg = this.i18n.translate(msgId, txp ? txp.HTTPLanguages : this.opContext.langs, [safeText(code), system, version, SCTVersion(system, version)]);
|
|
479
|
+
const issue = new Issue('error', 'code-invalid', 'concept', msgId, msg, 'invalid-code', 404);
|
|
480
|
+
if (message) {
|
|
481
|
+
issue.withDiagnostics(message);
|
|
482
|
+
}
|
|
483
|
+
return issue;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Client-supplied text going into a message (and so into the log): no control characters
|
|
489
|
+
* - a newline would let a client write lines of its own into the log - and not too long.
|
|
490
|
+
*/
|
|
491
|
+
function safeText(v) {
|
|
492
|
+
const s = String(v).replace(/\p{Cc}/gu, '?');
|
|
493
|
+
return s.length > 100 ? s.substring(0, 100) + '...' : s;
|
|
25
494
|
}
|
|
26
495
|
|
|
27
496
|
module.exports = ClosureWorker;
|
|
497
|
+
module.exports.LIMITS = LIMITS;
|
package/tx/workers/expand.js
CHANGED
|
@@ -1385,9 +1385,9 @@ class ValueSetExpander {
|
|
|
1385
1385
|
}
|
|
1386
1386
|
|
|
1387
1387
|
if (this.params.DisplayLanguages) {
|
|
1388
|
-
this.addParamCode(exp, 'displayLanguage', this.params.DisplayLanguages.
|
|
1388
|
+
this.addParamCode(exp, 'displayLanguage', this.params.DisplayLanguages.asParameter());
|
|
1389
1389
|
} else if (this.params.HTTPLanguages) {
|
|
1390
|
-
this.addParamCode(exp, 'displayLanguage', this.params.HTTPLanguages.
|
|
1390
|
+
this.addParamCode(exp, 'displayLanguage', this.params.HTTPLanguages.asParameter());
|
|
1391
1391
|
}
|
|
1392
1392
|
if (this.params.designations) {
|
|
1393
1393
|
for (const s of this.params.designations) {
|
package/tx/workers/metadata.js
CHANGED
|
@@ -272,8 +272,8 @@ class MetadataHandler {
|
|
|
272
272
|
{ name: '_id', type: 'token' }
|
|
273
273
|
],
|
|
274
274
|
operation: [
|
|
275
|
-
{ name: 'translate', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-translate' }
|
|
276
|
-
|
|
275
|
+
{ name: 'translate', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-translate' }
|
|
276
|
+
// closure is system level, not ConceptMap level - see rest.operation below
|
|
277
277
|
]
|
|
278
278
|
}
|
|
279
279
|
],
|
|
@@ -286,7 +286,8 @@ class MetadataHandler {
|
|
|
286
286
|
{ name: 'subsumes', definition: 'http://hl7.org/fhir/OperationDefinition/CodeSystem-subsumes' },
|
|
287
287
|
{ name: 'validate-code', definition: 'http://hl7.org/fhir/OperationDefinition/Resource-validate-code' },
|
|
288
288
|
{ name: 'translate', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-translate' },
|
|
289
|
-
|
|
289
|
+
// only when the administrator has turned it on (modules.tx.closure)
|
|
290
|
+
...(this.config.closure ? [{ name: 'closure', definition: 'http://hl7.org/fhir/OperationDefinition/ConceptMap-closure' }] : []),
|
|
290
291
|
{ name: 'compare', definition: 'http://hl7.org/fhir/tools/OperationDefinition/ValueSet-compare' },
|
|
291
292
|
{ name: 'cache-control', definition: 'http://hl7.org/fhir/tools/OperationDefinition/cache-control' },
|
|
292
293
|
{ name: 'versions', definition: 'http://hl7.org/fhir/OperationDefinition/fhir-versions' }
|
|
@@ -333,6 +334,10 @@ class MetadataHandler {
|
|
|
333
334
|
validateCode: this.buildValidateCodeCapabilities(),
|
|
334
335
|
translation: this.buildTranslationCapabilities()
|
|
335
336
|
};
|
|
337
|
+
if (this.config.closure) {
|
|
338
|
+
// closure tables relate concepts within one code system, never across them
|
|
339
|
+
tc.closure = { translation: false };
|
|
340
|
+
}
|
|
336
341
|
|
|
337
342
|
return tc;
|
|
338
343
|
}
|
package/tx/workers/read.js
CHANGED
|
@@ -61,6 +61,21 @@ class ReadWorker extends TerminologyWorker {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The resource as served at [type]/[id] must carry that id. Providers that
|
|
66
|
+
* share the server with others prefix their ids with a space id ("1-foo") but
|
|
67
|
+
* may keep the resource itself under its original id ("foo") - package
|
|
68
|
+
* ConceptMaps do - and then the HTML tabs, which link to [type]/[resource.id],
|
|
69
|
+
* point at a resource that doesn't exist (issue #256). Copy rather than
|
|
70
|
+
* mutate: the provider's object is shared and keyed on its original id.
|
|
71
|
+
*/
|
|
72
|
+
withId(json, id) {
|
|
73
|
+
if (!json || json.id === id) {
|
|
74
|
+
return json;
|
|
75
|
+
}
|
|
76
|
+
return { ...json, id: id };
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
/**
|
|
65
80
|
* Handle CodeSystem read
|
|
66
81
|
*/
|
|
@@ -68,7 +83,7 @@ class ReadWorker extends TerminologyWorker {
|
|
|
68
83
|
let cs = this.provider.getCodeSystemById(this.opContext, id);
|
|
69
84
|
if (cs != null) {
|
|
70
85
|
req.sourcePackage = cs.sourcePackage;
|
|
71
|
-
return res.json(cs.jsonObj);
|
|
86
|
+
return res.json(this.withId(cs.jsonObj, id));
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
if (id.startsWith("x-")) {
|
|
@@ -127,7 +142,7 @@ class ReadWorker extends TerminologyWorker {
|
|
|
127
142
|
const vs = await vsp.fetchValueSetById(id);
|
|
128
143
|
if (vs) {
|
|
129
144
|
req.sourcePackage = vs.sourcePackage;
|
|
130
|
-
return res.json(vs.jsonObj);
|
|
145
|
+
return res.json(this.withId(vs.jsonObj, id));
|
|
131
146
|
}
|
|
132
147
|
}
|
|
133
148
|
|
|
@@ -143,7 +158,7 @@ class ReadWorker extends TerminologyWorker {
|
|
|
143
158
|
const cm = await cmsp.fetchConceptMapById(id);
|
|
144
159
|
if (cm) {
|
|
145
160
|
req.sourcePackage = cm.sourcePackage;
|
|
146
|
-
return res.json(cm.jsonObj);
|
|
161
|
+
return res.json(this.withId(cm.jsonObj, id));
|
|
147
162
|
}
|
|
148
163
|
}
|
|
149
164
|
|
package/tx/workers/subsumes.js
CHANGED
|
@@ -107,9 +107,16 @@ class SubsumesWorker extends TerminologyWorker {
|
|
|
107
107
|
codingA = params.get('codingA');
|
|
108
108
|
codingB = params.get('codingB');
|
|
109
109
|
|
|
110
|
+
// A Coding with no system has no defined meaning, so there is nothing to test. This is a
|
|
111
|
+
// problem with the request (400/invalid), not a code system the server can't find
|
|
112
|
+
for (const [name, coding] of [['codingA', codingA], ['codingB', codingB]]) {
|
|
113
|
+
if (!coding.system) {
|
|
114
|
+
throw this.parameterIssue(txp, 'SUBSUMPTION_NO_SYSTEM', [name], name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
110
117
|
// Codings must have the same system
|
|
111
118
|
if (codingA.system !== codingB.system) {
|
|
112
|
-
throw
|
|
119
|
+
throw this.parameterIssue(txp, 'SUBSUMPTION_SYSTEM_MISMATCH', [codingA.system, codingB.system], 'codingB');
|
|
113
120
|
}
|
|
114
121
|
// Get the code system provider from the coding's system
|
|
115
122
|
csProvider = await this.findCodeSystem(codingA.system, codingA.version || '', txp, ['complete'], null, false);
|
|
@@ -117,7 +124,7 @@ class SubsumesWorker extends TerminologyWorker {
|
|
|
117
124
|
} else if (params.has('codeA') && params.has('codeB')) {
|
|
118
125
|
// Using codeA, codeB - system is required
|
|
119
126
|
if (!params.has('system')) {
|
|
120
|
-
throw
|
|
127
|
+
throw this.parameterIssue(txp, 'SUBSUMPTION_NO_SYSTEM', ['codeA'], 'codeA');
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
csProvider = await this.findCodeSystem(params.get('system'), params.get('version') || '', txp, ['complete'], null, false);
|
|
@@ -318,6 +325,15 @@ class SubsumesWorker extends TerminologyWorker {
|
|
|
318
325
|
};
|
|
319
326
|
}
|
|
320
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Build the Issue for a request that is malformed - a missing or unusable parameter. These
|
|
330
|
+
* are 400 / invalid: the request can't be processed as given, whatever the server knows
|
|
331
|
+
*/
|
|
332
|
+
parameterIssue(txp, msgId, args, path) {
|
|
333
|
+
const msg = this.i18n.translate(msgId, txp.HTTPLanguages, args);
|
|
334
|
+
return new Issue('error', 'invalid', path, msgId, msg, 'invalid-data', 400);
|
|
335
|
+
}
|
|
336
|
+
|
|
321
337
|
/**
|
|
322
338
|
* Build the Issue for a code that isn't in the code system. Uses the same message ids
|
|
323
339
|
* and tx-issue-type detail code as $validate-code, so clients get consistent errors
|