fhirsmith 0.11.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +377 -2
- package/README.md +1 -1
- package/config-template.json +31 -1
- package/library/languages.js +191 -34
- package/library/path-safety.js +51 -0
- package/library/request-token.js +56 -0
- package/package.json +2 -2
- package/packages/packages.js +17 -118
- package/publisher/publisher.js +208 -30
- package/publisher/validation.js +210 -0
- package/server.js +24 -15
- package/stats-db.js +127 -0
- package/stats.js +172 -6
- package/translations/Messages.properties +5 -1
- package/tx/README.md +21 -0
- package/tx/cs/cs-api.js +30 -1
- package/tx/cs/cs-api.md +1 -1
- package/tx/cs/cs-cs.js +1 -1
- package/tx/cs/cs-icd11.js +805 -0
- package/tx/cs/cs-lang.js +331 -85
- package/tx/cs/cs-loinc.js +45 -4
- package/tx/cs/cs-mimetypes.js +492 -21
- package/tx/cs/cs-snomed.js +141 -58
- package/tx/cs/cs-ucum.js +33 -3
- package/tx/cs/icd11-expressions.js +243 -0
- package/tx/importers/export-icd11-codesystem.js +823 -0
- package/tx/importers/icd11-schema.md +285 -0
- package/tx/importers/import-icd11.js +861 -0
- package/tx/importers/import-loinc.module.js +14 -7
- package/tx/importers/import-sct.module.js +10 -1
- package/tx/library/codesystem.js +21 -27
- package/tx/library/conceptmap.js +39 -0
- package/tx/library/nesting-limit.js +79 -0
- package/tx/library/operation-outcome.js +97 -3
- package/tx/library/parameter-check.js +139 -0
- package/tx/library/valueset.js +16 -20
- package/tx/library.js +23 -1
- package/tx/ocl/cache/cache-utils.cjs +5 -3
- package/tx/ocl/cs-ocl.cjs +73 -17
- package/tx/ocl/jobs/background-queue.cjs +6 -3
- package/tx/ocl/mappers/concept-mapper.cjs +9 -1
- package/tx/ocl/shared/patches.cjs +48 -1
- package/tx/ocl/vs-ocl.cjs +15 -13
- package/tx/operation-context.js +220 -10
- package/tx/params.js +226 -175
- package/tx/sct/expressions.js +677 -15
- package/tx/tx.fhir.org.yml +3 -2
- package/tx/tx.js +72 -92
- package/tx/workers/batch-validate.js +11 -18
- package/tx/workers/batch.js +16 -28
- package/tx/workers/cache-control.js +87 -17
- package/tx/workers/closure.js +3 -8
- package/tx/workers/compare.js +6 -34
- package/tx/workers/expand.js +108 -60
- package/tx/workers/lookup.js +35 -40
- package/tx/workers/read.js +6 -40
- package/tx/workers/search.js +2 -8
- package/tx/workers/subsumes.js +58 -41
- package/tx/workers/translate.js +472 -47
- package/tx/workers/validate.js +83 -78
- package/tx/workers/worker.js +92 -11
- package/tx/xml/xml-base.js +264 -77
- package/tx/xversion/xv-conceptmap.js +81 -0
- package/tx/xversion/xv-parameters.js +8 -0
- package/tx/xversion/xv-valueset.js +39 -1
- package/utilities/merge-audit.py +178 -0
- package/utilities/why-merge-audit.md +39 -0
- package/tx/data/snomed-subset.txt +0 -84
package/tx/cs/cs-mimetypes.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
15
|
-
const typeParts =
|
|
56
|
+
const segments = trimmed.split(';');
|
|
57
|
+
const typeParts = segments[0].trim().split('/');
|
|
16
58
|
|
|
17
|
-
if (typeParts.length
|
|
18
|
-
return {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|