fhirsmith 0.13.3 → 0.14.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 +47 -1
- package/README.md +48 -4
- package/config-template.json +70 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/nginx.md +391 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher.js +146 -14
- package/server.js +51 -14
- package/stats.js +19 -13
- package/testing/readme.md +112 -0
- package/testing/render.js +482 -0
- package/testing/search.js +378 -0
- package/testing/store.js +266 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +486 -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 +142 -27
- 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/extensions.js +2 -2
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +111 -15
- package/tx/provider.js +85 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/sct/text-index.js +419 -0
- 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 +20 -0
- package/tx/workers/metadata.js +29 -4
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +167 -20
- package/tx/workers/validate.js +61 -182
- package/tx/workers/worker.js +9 -0
- 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
|
@@ -861,6 +861,14 @@ class ValueSetExpander {
|
|
|
861
861
|
this.addToTotal();
|
|
862
862
|
}
|
|
863
863
|
}
|
|
864
|
+
if (this.pageFilled()) {
|
|
865
|
+
// No total is reported for this path (noTotal above), so once
|
|
866
|
+
// the requested page is full there is nothing left to learn
|
|
867
|
+
// from the rest of the matches - and a text filter on a large
|
|
868
|
+
// code system can match tens of thousands of them, each
|
|
869
|
+
// costing a designations/properties lookup.
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
864
872
|
}
|
|
865
873
|
this.worker.opContext.log('iterate filters done');
|
|
866
874
|
}
|
|
@@ -1792,6 +1800,18 @@ class ValueSetExpander {
|
|
|
1792
1800
|
pdv[valueName] = value;
|
|
1793
1801
|
}
|
|
1794
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* True when the requested page (offset + count) has been filled, so an
|
|
1805
|
+
* iteration that is not counting a total can stop. Exclusions are handled
|
|
1806
|
+
* after the fact and can remove codes already collected, so a value set
|
|
1807
|
+
* with any exclusion never stops early.
|
|
1808
|
+
* @returns {boolean}
|
|
1809
|
+
*/
|
|
1810
|
+
pageFilled() {
|
|
1811
|
+
return this.count > 0 && this.offset > -1 && !this.hasExclusions
|
|
1812
|
+
&& this.fullList.length >= this.count + this.offset;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1795
1815
|
addToTotal(t = 1) {
|
|
1796
1816
|
if (this.total > -1 && this.totalStatus != "off") {
|
|
1797
1817
|
this.total = this.total + t;
|
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
|
}
|
|
@@ -429,22 +434,34 @@ class MetadataHandler {
|
|
|
429
434
|
name: 'tx-resource',
|
|
430
435
|
documentation: 'Additional valuesets needed for evaluation e.g. value sets referred to from the import statement of the value set being expanded'
|
|
431
436
|
},
|
|
437
|
+
// Parameters the $expand handler reads - see handleTypeLevelExpand in
|
|
438
|
+
// tx/workers/expand.js and TxParameters.seeParameter in tx/params.js.
|
|
439
|
+
// Keep this list in step with those when adding a parameter.
|
|
432
440
|
{ name: '_incomplete' },
|
|
433
441
|
{ name: 'abstract' },
|
|
434
442
|
{ name: 'activeOnly' },
|
|
435
443
|
{ name: 'check-system-version' },
|
|
444
|
+
{ name: 'check-valueset-version' },
|
|
436
445
|
{ name: 'count' },
|
|
437
446
|
{ name: 'default-to-latest-version' },
|
|
447
|
+
{ name: 'default-valueset-version' },
|
|
448
|
+
{ name: 'designation' },
|
|
438
449
|
{ name: 'displayLanguage' },
|
|
439
450
|
{ name: 'excludeNested' },
|
|
440
451
|
{ name: 'excludeNotForUI' },
|
|
441
452
|
{ name: 'excludePostCoordinated' },
|
|
453
|
+
{ name: 'filter' },
|
|
442
454
|
{ name: 'force-system-version' },
|
|
455
|
+
{ name: 'force-valueset-version' },
|
|
443
456
|
{ name: 'inactive' },
|
|
444
457
|
{ name: 'includeAlternateCodes' },
|
|
445
458
|
{ name: 'includeDefinition' },
|
|
446
459
|
{ name: 'includeDesignations' },
|
|
447
460
|
{ name: 'incomplete-ok' },
|
|
461
|
+
{
|
|
462
|
+
name: 'limit',
|
|
463
|
+
documentation: 'Upper bound on the size of the expansion; the operation fails as too-costly beyond it'
|
|
464
|
+
},
|
|
448
465
|
{ name: 'limitedExpansion' },
|
|
449
466
|
{
|
|
450
467
|
name: 'mode',
|
|
@@ -454,11 +471,19 @@ class MetadataHandler {
|
|
|
454
471
|
{ name: 'offset' },
|
|
455
472
|
{ name: 'profile' },
|
|
456
473
|
{ name: 'property' },
|
|
474
|
+
{
|
|
475
|
+
name: 'sort',
|
|
476
|
+
documentation: 'code | display | design | prop:{name}; prefix with - to reverse. Ignored for hierarchical expansions'
|
|
477
|
+
},
|
|
457
478
|
{ name: 'system-version' },
|
|
479
|
+
{ name: 'url' },
|
|
480
|
+
{ name: 'useSupplement' },
|
|
481
|
+
{ name: 'valueSet' },
|
|
458
482
|
{
|
|
459
483
|
name: 'valueSetMode',
|
|
460
484
|
documentation: '= CHECK_MEMBERSHIP_ONLY | NO_MEMBERSHIP_CHECK'
|
|
461
|
-
}
|
|
485
|
+
},
|
|
486
|
+
{ name: 'valueSetVersion' }
|
|
462
487
|
]
|
|
463
488
|
};
|
|
464
489
|
}
|
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
|
|