fhirsmith 0.12.0 → 0.13.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +323 -4
  2. package/README.md +1 -1
  3. package/config-template.json +13 -0
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +60 -30
  10. package/publisher/validation.js +15 -3
  11. package/server.js +12 -3
  12. package/translations/Messages.properties +1 -0
  13. package/tx/README.md +21 -0
  14. package/tx/cs/cs-api.js +30 -1
  15. package/tx/cs/cs-cs.js +14 -1
  16. package/tx/cs/cs-icd11.js +844 -0
  17. package/tx/cs/cs-lang.js +331 -85
  18. package/tx/cs/cs-loinc.js +45 -4
  19. package/tx/cs/cs-mimetypes.js +492 -21
  20. package/tx/cs/cs-snomed.js +38 -4
  21. package/tx/cs/cs-ucum.js +33 -3
  22. package/tx/cs/icd11-expressions.js +243 -0
  23. package/tx/importers/export-icd11-codesystem.js +823 -0
  24. package/tx/importers/icd11-schema.md +285 -0
  25. package/tx/importers/import-icd11.js +861 -0
  26. package/tx/importers/import-loinc.module.js +14 -7
  27. package/tx/importers/import-sct.module.js +10 -1
  28. package/tx/library/codesystem.js +21 -27
  29. package/tx/library/nesting-limit.js +79 -0
  30. package/tx/library/operation-outcome.js +97 -3
  31. package/tx/library/valueset.js +16 -20
  32. package/tx/library.js +23 -1
  33. package/tx/ocl/cache/cache-utils.cjs +5 -3
  34. package/tx/ocl/cs-ocl.cjs +73 -17
  35. package/tx/ocl/jobs/background-queue.cjs +6 -3
  36. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  37. package/tx/ocl/shared/patches.cjs +48 -1
  38. package/tx/ocl/vs-ocl.cjs +15 -13
  39. package/tx/provider.js +1 -1
  40. package/tx/sct/expressions.js +194 -17
  41. package/tx/tx.fhir.org.yml +2 -1
  42. package/tx/tx.js +15 -42
  43. package/tx/workers/batch-validate.js +6 -16
  44. package/tx/workers/batch.js +16 -28
  45. package/tx/workers/cache-control.js +6 -12
  46. package/tx/workers/closure.js +3 -8
  47. package/tx/workers/compare.js +6 -34
  48. package/tx/workers/expand.js +36 -45
  49. package/tx/workers/lookup.js +21 -39
  50. package/tx/workers/read.js +6 -40
  51. package/tx/workers/search.js +2 -8
  52. package/tx/workers/subsumes.js +58 -41
  53. package/tx/workers/translate.js +11 -17
  54. package/tx/workers/validate.js +76 -77
  55. package/tx/workers/worker.js +70 -1
  56. package/tx/xml/xml-base.js +243 -76
  57. package/tx/xversion/xv-valueset.js +39 -1
@@ -1,6 +1,46 @@
1
- const { CodeSystemProvider, CodeSystemFactoryProvider} = require('./cs-api');
1
+ const { CodeSystemProvider, CodeSystemFactoryProvider, FilterExecutionContext, cannotDetermineSubsumption} = require('./cs-api');
2
2
  const assert = require('assert');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const axios = require('axios');
6
+ const { XMLParser } = require('fast-xml-parser');
3
7
  const { CodeSystem } = require("../library/codesystem");
8
+ const { Issue } = require("../library/operation-outcome");
9
+
10
+ /**
11
+ * Media type parameters this server understands well enough to reason about subsumption.
12
+ *
13
+ * Deciding whether one media type subsumes another needs two things of a parameter: that
14
+ * adding it NARROWS the type, and that two different values are mutually exclusive. Both
15
+ * are properties of the specific parameter, not of parameters in general, so they can
16
+ * only be asserted for parameters whose definition is known. Where an unknown parameter
17
+ * is what distinguishes the two codes, the answer is not knowable and the server says so
18
+ * rather than guessing (FHIR-58748).
19
+ *
20
+ * - charset (RFC 2046) narrows, and two charsets are different encodings
21
+ * - format (RFC 3676, text/plain) fixed vs flowed, mutually exclusive
22
+ * - delsp (RFC 3676) yes vs no
23
+ * - version narrows, and two versions are distinct
24
+ *
25
+ * Deliberately NOT here, though both are common:
26
+ * - boundary (multipart, RFC 2046) does not narrow at all - two multipart bodies with
27
+ * different boundaries are the same media type, so treating a differing boundary as
28
+ * "not subsumed" would be wrong, and treating it as narrowing would be worse
29
+ * - profile values can be hierarchical (one profile derived from another), so two
30
+ * different values are not necessarily mutually exclusive
31
+ *
32
+ * Both of those therefore yield cannot-determine, which is the honest answer until
33
+ * someone implements what they actually mean.
34
+ *
35
+ * @type {Set<string>}
36
+ */
37
+ const UNDERSTOOD_PARAMETERS = new Set(['charset', 'format', 'delsp', 'version']);
38
+
39
+ // The IANA media types registry. Only the names are kept - the registry is a few MB of
40
+ // cross-references and templates, and all the `registered` filter asks is whether a type is in it
41
+ const IANA_MEDIA_TYPES_URL = 'https://www.iana.org/assignments/media-types/media-types.xml';
42
+ const IANA_MEDIA_TYPES_FILE = 'media-types.xml';
43
+ const IANA_DOWNLOAD_TIMEOUT = 15000;
4
44
 
5
45
  class MimeTypeConcept {
6
46
  constructor(code) {
@@ -9,24 +49,41 @@ class MimeTypeConcept {
9
49
  }
10
50
 
11
51
  #parseMimeType(code) {
12
- // Basic MIME type parsing - type/subtype with optional parameters
52
+ // type/subtype with optional parameters. Type, subtype and parameter names are all
53
+ // case-insensitive (RFC 9110); parameter values are case-sensitive unless the parameter
54
+ // says otherwise, and charset is the one in common use that says otherwise (RFC 2046)
13
55
  const trimmed = code.trim();
14
- const parts = trimmed.split(';')[0].trim(); // Remove parameters for validation
15
- const typeParts = parts.split('/');
56
+ const segments = trimmed.split(';');
57
+ const typeParts = segments[0].trim().split('/');
16
58
 
17
- if (typeParts.length === 2 && typeParts[0] && typeParts[1]) {
18
- return {
19
- type: typeParts[0],
20
- subtype: typeParts[1],
21
- isValid: true,
22
- source: trimmed
23
- };
59
+ if (typeParts.length !== 2 || !typeParts[0].trim() || !typeParts[1].trim()) {
60
+ return { type: '', subtype: '', params: new Map(), isValid: false, source: trimmed };
61
+ }
62
+
63
+ const params = new Map();
64
+ for (const segment of segments.slice(1)) {
65
+ const eq = segment.indexOf('=');
66
+ if (eq === -1) {
67
+ continue;
68
+ }
69
+ const name = segment.slice(0, eq).trim().toLowerCase();
70
+ let value = segment.slice(eq + 1).trim();
71
+ if (value.length > 1 && value.startsWith('"') && value.endsWith('"')) {
72
+ value = value.slice(1, -1);
73
+ }
74
+ if (name === 'charset') {
75
+ value = value.toLowerCase();
76
+ }
77
+ if (name) {
78
+ params.set(name, value);
79
+ }
24
80
  }
25
81
 
26
82
  return {
27
- type: '',
28
- subtype: '',
29
- isValid: false,
83
+ type: typeParts[0].trim().toLowerCase(),
84
+ subtype: typeParts[1].trim().toLowerCase(),
85
+ params: params,
86
+ isValid: true,
30
87
  source: trimmed
31
88
  };
32
89
  }
@@ -36,9 +93,70 @@ class MimeTypeConcept {
36
93
  }
37
94
  }
38
95
 
96
+ /**
97
+ * The set produced by a `base` filter: the type, and the subtype when one was given. Media types
98
+ * cannot be enumerated, so this set is only ever tested against a code, never iterated.
99
+ */
100
+ class MimeTypeFilter {
101
+ constructor(type, subtype) {
102
+ this.type = type;
103
+ this.subtype = subtype;
104
+ }
105
+
106
+ /**
107
+ * Is a parsed media type consistent with this base? Parameters on the code are irrelevant -
108
+ * `text/plain; charset=utf-8` is still a text/plain
109
+ * @param {Object} mimeType - the parsed mimeType of a MimeTypeConcept
110
+ * @returns {boolean}
111
+ */
112
+ matches(mimeType) {
113
+ if (mimeType.type !== this.type) {
114
+ return false;
115
+ }
116
+ return this.subtype === null || mimeType.subtype === this.subtype;
117
+ }
118
+
119
+ describe() {
120
+ return this.subtype === null ? this.type : this.type + '/' + this.subtype;
121
+ }
122
+
123
+ rejection(code) {
124
+ return `The mime type '${code}' is not a ${this.describe()}`;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The set produced by a `registered` filter: whether the code has to be in the IANA registry,
130
+ * or has to not be. Like MimeTypeFilter it can only be tested, never iterated.
131
+ */
132
+ class MimeTypeRegisteredFilter {
133
+ constructor(registered) {
134
+ this.registered = registered;
135
+ this.codes = null; // set when the filter is being iterated: the registry, sorted
136
+ this.cursor = -1;
137
+ }
138
+
139
+ describe() {
140
+ return this.registered ? 'registered with IANA' : 'not registered with IANA';
141
+ }
142
+
143
+ rejection(code) {
144
+ return this.registered
145
+ ? `The mime type '${code}' is not registered with IANA`
146
+ : `The mime type '${code}' is registered with IANA, and this filter is for media types that are not`;
147
+ }
148
+ }
149
+
39
150
  class MimeTypeServices extends CodeSystemProvider {
40
- constructor(opContext, supplements) {
151
+ /**
152
+ * @param {OperationContext} opContext
153
+ * @param {CodeSystem[]} supplements
154
+ * @param {Set<string>|null} registeredTypes - lower-cased type/subtype names from the IANA
155
+ * registry, or null if no copy of the registry could be obtained
156
+ */
157
+ constructor(opContext, supplements, registeredTypes = null) {
41
158
  super(opContext, supplements);
159
+ this.registeredTypes = registeredTypes;
42
160
  }
43
161
 
44
162
  // Metadata methods
@@ -166,12 +284,250 @@ class MimeTypeServices extends CodeSystemProvider {
166
284
  return { context: null, message: undefined};
167
285
  }
168
286
 
169
- // Subsumption - not supported
287
+ /**
288
+ * Media types have no registered hierarchy - one subtype is never a kind of another, and a
289
+ * structured syntax suffix does not make one either: `application/fhir+xml` says it is XML
290
+ * syntax (RFC 6839), but it is a separate registration and `Accept: application/xml` does
291
+ * not match it.
292
+ *
293
+ * Parameters are a different matter. A parameter narrows the type: everything true of
294
+ * `text/plain` is true of `text/plain; charset=utf-8`, and anything that is the latter is
295
+ * also the former. So within one type/subtype, the media type with fewer parameters subsumes
296
+ * the one that adds to them, provided they agree on the parameters they share.
297
+ *
298
+ * That reasoning only holds for parameters the server understands - see
299
+ * UNDERSTOOD_PARAMETERS. Where the two codes differ in a parameter it does not, the
300
+ * relationship cannot be determined and this throws rather than guessing.
301
+ *
302
+ * @param {string|MimeTypeConcept} codeA
303
+ * @param {string|MimeTypeConcept} codeB
304
+ * @returns {string} equivalent, subsumes, subsumed-by or not-subsumed
305
+ * @throws {Error} cannot-determine, when an unknown parameter is what differs
306
+ */
170
307
  async subsumesTest(codeA, codeB) {
308
+ const a = (await this.#ensureContext(codeA)).mimeType;
309
+ const b = (await this.#ensureContext(codeB)).mimeType;
310
+
311
+ if (a.type !== b.type || a.subtype !== b.subtype) {
312
+ // Decidable whatever the parameters say: no parameter makes one type a kind of
313
+ // another, so an unknown one cannot change this answer.
314
+ return 'not-subsumed';
315
+ }
316
+
317
+ // Only the parameters the two codes DISAGREE on can decide the answer - one present
318
+ // on one side only, or present on both with different values. A parameter carried
319
+ // identically by both, understood or not, cannot affect the result and is ignored.
320
+ const deciding = [...new Set([...a.params.keys(), ...b.params.keys()])]
321
+ .filter(name => a.params.get(name) !== b.params.get(name));
322
+ const unknown = deciding.filter(name => !UNDERSTOOD_PARAMETERS.has(name));
323
+ if (unknown.length > 0) {
324
+ throw cannotDetermineSubsumption(
325
+ `Unable to determine the subsumption relationship between '${a.source}' and `
326
+ + `'${b.source}': they differ in the parameter${unknown.length > 1 ? 's' : ''} `
327
+ + unknown.map(n => `'${n}'`).join(', ')
328
+ + ', which this server does not know the meaning of. Whether such a parameter narrows '
329
+ + 'the media type, and whether two of its values exclude one another, depends on the '
330
+ + 'definition of that particular parameter, so no outcome is returned rather than one '
331
+ + 'that may be wrong');
332
+ }
333
+
334
+ // Any parameter they both carry has to agree, or neither includes the other
335
+ for (const [name, value] of a.params) {
336
+ if (b.params.has(name) && b.params.get(name) !== value) {
337
+ return 'not-subsumed';
338
+ }
339
+ }
340
+
341
+ const aInB = [...a.params.keys()].every(name => b.params.has(name));
342
+ const bInA = [...b.params.keys()].every(name => a.params.has(name));
343
+
344
+ if (aInB && bInA) {
345
+ return 'equivalent';
346
+ }
347
+ if (aInB) {
348
+ return 'subsumes';
349
+ }
350
+ if (bInA) {
351
+ return 'subsumed-by';
352
+ }
353
+ return 'not-subsumed';
354
+ }
355
+
356
+ // ========== Filter Methods ==========
357
+
358
+ /**
359
+ * `base` takes either `type` or `type/subtype` and tests that a code is consistent with it,
360
+ * ignoring any parameters the code carries. Media types cannot be enumerated, so this filter
361
+ * can only ever be tested against a code, never expanded.
362
+ */
363
+ async doesFilter(prop, op, value) {
364
+ assert(prop != null && typeof prop === 'string', 'prop must be a non-null string');
365
+ assert(op != null && typeof op === 'string', 'op must be a non-null string');
366
+ assert(value != null && typeof value === 'string', 'value must be a non-null string');
367
+
368
+ return (prop === 'base' || prop === 'registered') && op === '=';
369
+ }
370
+
371
+ /**
372
+ * Parse a `base` filter value. Wildcards are refused rather than quietly matching nothing:
373
+ * `text/*` is Accept-header range syntax, not a media type, and the server takes no position
374
+ * on it - see subsumesTest
375
+ * @param {string} value
376
+ * @returns {MimeTypeFilter}
377
+ */
378
+ #parseBase(value) {
379
+ const trimmed = (value || '').trim();
380
+ if (!trimmed) {
381
+ throw new Error('The base filter needs a value of the form "type" or "type/subtype"');
382
+ }
383
+ if (trimmed.includes(';')) {
384
+ throw new Error(`Invalid base filter '${value}': a base is a type or type/subtype, and carries no parameters`);
385
+ }
386
+ if (trimmed.includes('*')) {
387
+ throw new Error(`Invalid base filter '${value}': wildcards are not supported`);
388
+ }
389
+ const parts = trimmed.split('/');
390
+ if (parts.length > 2 || parts.some(p => !p.trim())) {
391
+ throw new Error(`Invalid base filter '${value}': expected "type" or "type/subtype"`);
392
+ }
393
+ return new MimeTypeFilter(parts[0].trim().toLowerCase(),
394
+ parts.length === 2 ? parts[1].trim().toLowerCase() : null);
395
+ }
396
+
397
+ async filter(filterContext, forIteration, prop, op, value) {
398
+ assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext');
399
+ assert(prop != null && typeof prop === 'string', 'prop must be a non-null string');
400
+ assert(op != null && typeof op === 'string', 'op must be a non-null string');
401
+ assert(value != null && typeof value === 'string', 'value must be a non-null string');
402
+
403
+ if (prop !== 'base' && prop !== 'registered') {
404
+ throw new Error(`Unsupported filter property: ${prop}`);
405
+ }
406
+ if (op !== '=') {
407
+ throw new Error(`Unsupported filter operator for ${prop}: ${op}`);
408
+ }
409
+ // forIteration marks the filter the expander will walk; any others are only ever used as
410
+ // predicates over what that one produces, and those do not have to be enumerable
411
+ const set = prop === 'base' ? this.#parseBase(value) : this.#parseRegistered(value);
412
+
413
+ // `registered = true` is the one filter here that can be iterated: the registry is a finite
414
+ // list, and we have it. The expansion is still incomplete, because every registered type also
415
+ // has unboundedly many parameterised forms that belong to the value set - filtersNotClosed()
416
+ // says so, and the expander marks the expansion unclosed. Nothing else can be enumerated:
417
+ // `registered = false` is everything IANA has not registered, and a `base` covers every
418
+ // parameterisation of a type, both of them infinite.
419
+ if (forIteration) {
420
+ if (set instanceof MimeTypeRegisteredFilter && set.registered) {
421
+ set.codes = [...this.registeredTypes].sort();
422
+ } else {
423
+ throw new Issue('error', 'not-supported', null, 'CODESYSTEM_NOT_ENUMERABLE',
424
+ this.opContext.i18n.translate('CODESYSTEM_NOT_ENUMERABLE', this.opContext.langs, [this.system()]),
425
+ null, 422);
426
+ }
427
+ }
428
+
429
+ filterContext.filters.push(set);
430
+ }
431
+
432
+ /**
433
+ * Parse a `registered` filter value. This is where the absence of the IANA registry is
434
+ * reported: the server has no way to answer the question, and saying 'not registered' to
435
+ * everything would be a confident wrong answer rather than a missing one
436
+ * @param {string} value - true or false
437
+ * @returns {MimeTypeRegisteredFilter}
438
+ */
439
+ #parseRegistered(value) {
440
+ const trimmed = (value || '').trim().toLowerCase();
441
+ if (trimmed !== 'true' && trimmed !== 'false') {
442
+ throw new Error(`Invalid registered filter '${value}': the value must be true or false`);
443
+ }
444
+ if (!this.registeredTypes) {
445
+ throw new Error('The registered filter cannot be used: no copy of the IANA media types '
446
+ + 'registry is available (it could not be downloaded, and none is cached)');
447
+ }
448
+ return new MimeTypeRegisteredFilter(trimmed === 'true');
449
+ }
450
+
451
+ /**
452
+ * Is a parsed media type in the IANA registry? Parameters are irrelevant - it is the
453
+ * type/subtype that is registered
454
+ * @param {Object} mimeType - the parsed mimeType of a MimeTypeConcept
455
+ * @returns {boolean}
456
+ */
457
+ #isRegistered(mimeType) {
458
+ return this.registeredTypes.has(mimeType.type + '/' + mimeType.subtype);
459
+ }
460
+
461
+ async executeFilters(filterContext) {
462
+ assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext');
463
+ return filterContext.filters;
464
+ }
465
+
466
+ async filtersNotClosed(filterContext) {
467
+ assert(filterContext && filterContext instanceof FilterExecutionContext, 'filterContext must be a FilterExecutionContext');
468
+ return true; // every type/subtype has unboundedly many parameterised forms
469
+ }
470
+
471
+ async filterSize(filterContext, set) {
472
+ assert(set && this.#isFilterSet(set), 'set must be a mime type filter');
473
+ if (set.codes) {
474
+ return set.codes.length;
475
+ }
476
+ throw new Error('Mime types cannot be enumerated, so this filter has no size');
477
+ }
478
+
479
+ async filterMore(filterContext, set) {
480
+ assert(set && this.#isFilterSet(set), 'set must be a mime type filter');
481
+ if (!set.codes) {
482
+ // Returning false here would hand back an empty expansion, which reads as "there are no
483
+ // such media types" rather than "this cannot be enumerated"
484
+ if (filterContext.forIterate) {
485
+ throw new Error('Mime types cannot be enumerated, so this filter cannot be walked');
486
+ }
487
+ return false;
488
+ }
489
+ set.cursor++;
490
+ return set.cursor < set.codes.length;
491
+ }
492
+
493
+ async filterConcept(filterContext, set) {
494
+ assert(set && this.#isFilterSet(set), 'set must be a mime type filter');
495
+ if (set.codes) {
496
+ return new MimeTypeConcept(set.codes[set.cursor]);
497
+ }
498
+ throw new Error('Mime types cannot be enumerated, so this filter has no concepts to walk');
499
+ }
500
+
501
+ async filterLocate(filterContext, set, code) {
502
+ assert(set && this.#isFilterSet(set), 'set must be a mime type filter');
503
+ assert(typeof code === 'string', 'code must be a non-null string');
504
+
505
+ const found = await this.locate(code);
506
+ if (!found.context) {
507
+ return found.message ? found.message : `Invalid mime type '${code}'`;
508
+ }
509
+ if (!this.#matches(set, found.context.mimeType)) {
510
+ return set.rejection(code);
511
+ }
512
+ return found.context;
513
+ }
514
+
515
+ async filterCheck(filterContext, set, concept) {
516
+ assert(set && this.#isFilterSet(set), 'set must be a mime type filter');
517
+
518
+ const ctxt = await this.#ensureContext(concept);
519
+ return this.#matches(set, ctxt.mimeType);
520
+ }
171
521
 
172
- await this.#ensureContext(codeA);
173
- await this.#ensureContext(codeB);
174
- return 'not-subsumed'; // No subsumption relationships
522
+ #isFilterSet(set) {
523
+ return set instanceof MimeTypeFilter || set instanceof MimeTypeRegisteredFilter;
524
+ }
525
+
526
+ #matches(set, mimeType) {
527
+ if (set instanceof MimeTypeFilter) {
528
+ return set.matches(mimeType);
529
+ }
530
+ return this.#isRegistered(mimeType) === set.registered;
175
531
  }
176
532
 
177
533
  async locateIsA(code, parent) {
@@ -195,9 +551,123 @@ class MimeTypeServices extends CodeSystemProvider {
195
551
  }
196
552
 
197
553
  class MimeTypeServicesFactory extends CodeSystemFactoryProvider {
198
- constructor(i18n) {
554
+ /**
555
+ * @param {I18nSupport} i18n
556
+ * @param {string} cacheFolder - where the downloaded copy of the IANA registry is kept
557
+ * @param {Logger} log
558
+ */
559
+ constructor(i18n, cacheFolder = null, log = null) {
199
560
  super(i18n);
200
561
  this.uses = 0;
562
+ this.cacheFolder = cacheFolder;
563
+ this.log = log;
564
+ this.registeredTypes = null;
565
+ }
566
+
567
+ /**
568
+ * Try to refresh the IANA media types registry, then load whatever copy we have. A failed
569
+ * download is not fatal - an older cached copy still answers the question - but if there is
570
+ * no copy at all the `registered` filter reports that rather than guessing (see
571
+ * MimeTypeServices#parseRegistered).
572
+ */
573
+ async load() {
574
+ if (!this.cacheFolder) {
575
+ return; // no cache configured: the registered filter is unavailable
576
+ }
577
+ const target = path.join(this.cacheFolder, IANA_MEDIA_TYPES_FILE);
578
+ await this.#refresh(target);
579
+
580
+ if (fs.existsSync(target)) {
581
+ try {
582
+ this.registeredTypes = MimeTypeServicesFactory.parseRegistry(fs.readFileSync(target, 'utf8'));
583
+ this.log?.info(`Mime types: ${this.registeredTypes.size} registered media types`);
584
+ } catch (e) {
585
+ this.log?.warn(`Mime types: could not read the IANA registry at ${target}: ${e.message}`);
586
+ this.registeredTypes = null;
587
+ }
588
+ } else {
589
+ this.log?.warn('Mime types: no copy of the IANA media types registry; the registered filter will not be available');
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Download the registry over the top of the cached copy. Writes to a temporary file and
595
+ * renames, so a download that fails part way through cannot leave a truncated registry
596
+ * behind - which would look like a valid but much smaller registry.
597
+ * @param {string} target
598
+ */
599
+ async #refresh(target) {
600
+ const temp = target + '.tmp';
601
+ try {
602
+ if (!fs.existsSync(this.cacheFolder)) {
603
+ fs.mkdirSync(this.cacheFolder, { recursive: true });
604
+ }
605
+ const response = await axios.get(IANA_MEDIA_TYPES_URL, {
606
+ timeout: IANA_DOWNLOAD_TIMEOUT,
607
+ responseType: 'text',
608
+ transformResponse: [d => d]
609
+ });
610
+ // Check it parses before it replaces a copy that is known to work
611
+ const names = MimeTypeServicesFactory.parseRegistry(response.data);
612
+ fs.writeFileSync(temp, response.data, 'utf8');
613
+ fs.renameSync(temp, target);
614
+ this.log?.info(`Mime types: downloaded the IANA registry (${names.size} media types)`);
615
+ } catch (e) {
616
+ if (fs.existsSync(temp)) {
617
+ try { fs.unlinkSync(temp); } catch { /* nothing useful to do */ }
618
+ }
619
+ this.log?.warn(`Mime types: could not download the IANA registry (${e.message}); using the cached copy if there is one`);
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Pull the media type names out of the IANA registry document. The document is a registry of
625
+ * registries: the outer one is `media-types`, and each inner one is a type (application,
626
+ * text, ...) whose records are its subtypes. A record's `file` element carries the full
627
+ * type/subtype; where it is missing the registry id and the record name give the same thing.
628
+ *
629
+ * Names in the registry sometimes carry annotations - "example (OBSOLETE)" and the like - so
630
+ * anything after the first whitespace is dropped.
631
+ *
632
+ * @param {string} xml
633
+ * @returns {Set<string>} lower-cased type/subtype names
634
+ * @throws if the document yields no names at all, which means it is not the registry
635
+ */
636
+ static parseRegistry(xml) {
637
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true });
638
+ const doc = parser.parse(xml);
639
+ const root = doc.registry;
640
+ if (!root) {
641
+ throw new Error('not an IANA registry document (no registry element)');
642
+ }
643
+ const names = new Set();
644
+ const registries = Array.isArray(root.registry) ? root.registry : (root.registry ? [root.registry] : []);
645
+ for (const registry of registries) {
646
+ const type = String(registry['@_id'] || '').trim().toLowerCase();
647
+ const records = Array.isArray(registry.record) ? registry.record : (registry.record ? [registry.record] : []);
648
+ for (const record of records) {
649
+ let name = null;
650
+ if (record.file) {
651
+ const file = typeof record.file === 'object' ? record.file['#text'] : record.file;
652
+ if (file) {
653
+ name = String(file);
654
+ }
655
+ }
656
+ if (!name && record.name && type) {
657
+ name = type + '/' + String(typeof record.name === 'object' ? record.name['#text'] : record.name);
658
+ }
659
+ if (name) {
660
+ name = name.trim().split(/\s/)[0].toLowerCase();
661
+ if (name.includes('/')) {
662
+ names.add(name);
663
+ }
664
+ }
665
+ }
666
+ }
667
+ if (names.size === 0) {
668
+ throw new Error('no media types found - this does not look like the IANA registry');
669
+ }
670
+ return names;
201
671
  }
202
672
 
203
673
  defaultVersion() {
@@ -219,7 +689,7 @@ class MimeTypeServicesFactory extends CodeSystemFactoryProvider {
219
689
 
220
690
  build(opContext, supplements) {
221
691
  this.uses++;
222
- return new MimeTypeServices(opContext, supplements);
692
+ return new MimeTypeServices(opContext, supplements, this.registeredTypes);
223
693
  }
224
694
 
225
695
  useCount() {
@@ -240,6 +710,7 @@ class MimeTypeServicesFactory extends CodeSystemFactoryProvider {
240
710
  }
241
711
 
242
712
  module.exports = {
713
+ MimeTypeFilter,
243
714
  MimeTypeServices,
244
715
  MimeTypeServicesFactory,
245
716
  MimeTypeConcept
@@ -1,4 +1,4 @@
1
- const { CodeSystemContentMode, CodeSystemFactoryProvider} = require('./cs-api');
1
+ const { CodeSystemContentMode, CodeSystemFactoryProvider, cannotDetermineSubsumption} = require('./cs-api');
2
2
  const {
3
3
  SnomedStrings, SnomedWords, SnomedStems, SnomedReferences,
4
4
  SnomedDescriptions, SnomedDescriptionIndex, SnomedConceptList,
@@ -2393,9 +2393,21 @@ class SnomedProvider extends BaseCSServices {
2393
2393
  }
2394
2394
 
2395
2395
  // Subsumption testing
2396
+ /**
2397
+ * Subsumption between two codes, either of which may be a postcoordinated expression.
2398
+ *
2399
+ * PRECONDITION: both codes have already been validated. doSubsumes (the only caller)
2400
+ * runs locate() on each first, which parses and runs checkExpression - so an
2401
+ * expression that breaks the concept model is reported as an invalid code before it
2402
+ * gets here, rather than having a subsumption answer computed for it. The check is
2403
+ * deliberately not repeated here: it evaluates ECL for the MRCM range constraints, and
2404
+ * doing that twice per request is not free.
2405
+ *
2406
+ * @param {string} codeA
2407
+ * @param {string} codeB
2408
+ * @returns {Promise<string>} equivalent | subsumes | subsumed-by | not-subsumed
2409
+ */
2396
2410
  async subsumesTest(codeA, codeB) {
2397
-
2398
-
2399
2411
  try {
2400
2412
  const exprA = new SnomedExpressionParser(this.sct.concepts).parse(codeA);
2401
2413
  const exprB = new SnomedExpressionParser(this.sct.concepts).parse(codeB);
@@ -2424,10 +2436,32 @@ class SnomedProvider extends BaseCSServices {
2424
2436
  } else if (b2) {
2425
2437
  return 'subsumed-by';
2426
2438
  } else {
2427
- return 'not-subsumed';
2439
+ // No structural relationship found - which is NOT the same as there being
2440
+ // none. Structural comparison of normal forms is sound when it succeeds (a
2441
+ // proof is a proof) but not when it fails: SNOMED's OWL axiom reference set
2442
+ // carries general concept inclusions and property chains whose entailments
2443
+ // are baked into the distributed inferred relationships for precoordinated
2444
+ // concepts, but a postcoordinated expression is one the classifier never saw,
2445
+ // so those entailments are not available for it. Answering 'not-subsumed'
2446
+ // here would assert something we have not established, in the direction that
2447
+ // wrongly excludes a code from a cohort.
2448
+ //
2449
+ // $subsumes has four outcome codes and none of them means "unknown", so the
2450
+ // only honest answer is an error. Both codes are valid and the request is
2451
+ // well formed, hence 422 rather than 400.
2452
+ throw cannotDetermineSubsumption('Unable to determine the subsumption relationship between '
2453
+ + `'${codeA}' and '${codeB}': at least one is a postcoordinated expression and no `
2454
+ + 'relationship follows from comparing their normal forms. Establishing that there '
2455
+ + 'is no relationship would require classification, which this server does not do, '
2456
+ + 'so no outcome is returned rather than one that may be wrong');
2428
2457
  }
2429
2458
  }
2430
2459
  } catch (error) {
2460
+ // Errors that already say how they should be reported pass straight through -
2461
+ // wrapping them would lose the status and issue code.
2462
+ if (error.statusCode) {
2463
+ throw error;
2464
+ }
2431
2465
  throw new Error(`Error in subsumption test: ${error.message}`);
2432
2466
  }
2433
2467
  }
package/tx/cs/cs-ucum.js CHANGED
@@ -7,6 +7,7 @@ const { FilterExecutionContext, CodeSystemFactoryProvider} = require('./cs-api')
7
7
  const ValueSet = require("../library/valueset");
8
8
  const assert = require('assert');
9
9
  const {UcumService} = require("../library/ucum-service");
10
+ const {Pair, Decimal} = require("../library/ucum-types");
10
11
  const {validateArrayParameter, validateParameter, validateOptionalParameter} = require("../../library/utilities");
11
12
  const {DesignationUse} = require("../library/designations");
12
13
  const {BaseCSServices} = require("./cs-base");
@@ -397,11 +398,40 @@ class UcumCodeSystemProvider extends BaseCSServices {
397
398
  return codeA === codeB;
398
399
  }
399
400
 
401
+ /**
402
+ * UCUM has no hierarchy, so no code is ever a subtype of another. Two different codes can
403
+ * still mean the same unit though - 1/min and min-1 are the same thing written two ways -
404
+ * so when the codes differ, compare what they canonicalise to.
405
+ *
406
+ * This is the canonical *form*, not the canonical units: getCanonicalUnits() drops the
407
+ * magnitude, so m and cm both answer 'm' and would compare equal when they are different
408
+ * units. getCanonicalForm() carries the conversion factor with the unit, which is what
409
+ * distinguishes the same unit written differently from a merely comparable one.
410
+ *
411
+ * @param {string|UcumContext} codeA
412
+ * @param {string|UcumContext} codeB
413
+ * @returns {string} equivalent or not-subsumed
414
+ */
400
415
  async subsumesTest(codeA, codeB) {
416
+ const a = await this.#ensureContext(codeA);
417
+ const b = await this.#ensureContext(codeB);
401
418
 
402
- await this.#ensureContext(codeA);
403
- await this.#ensureContext(codeB);
404
- return 'not-subsumed'; // No subsumption in UCUM
419
+ if (a.code === b.code) {
420
+ return 'equivalent';
421
+ }
422
+
423
+ try {
424
+ const ca = this.ucumService.getCanonicalForm(new Pair(Decimal.one(), a.code));
425
+ const cb = this.ucumService.getCanonicalForm(new Pair(Decimal.one(), b.code));
426
+ if (ca.getCode() === cb.getCode() && ca.getValue().comparesTo(cb.getValue()) === 0) {
427
+ return 'equivalent';
428
+ }
429
+ } catch (e) {
430
+ // a unit that won't canonicalise is simply not equivalent to anything. locate() has
431
+ // already accepted both codes, so this is a unit the analyser can't reduce, not junk
432
+ }
433
+
434
+ return 'not-subsumed';
405
435
  }
406
436
 
407
437
  async extendLookup(ctxt, props, params) {