fhirsmith 0.13.0 → 0.13.2
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 +35 -5
- package/config-template.json +5 -0
- package/library/html-server.js +3 -1
- package/package.json +2 -2
- package/translations/Messages.properties +1 -0
- package/tx/README.md +22 -0
- package/tx/cs/cs-cs.js +13 -0
- package/tx/cs/cs-icd11.js +47 -8
- package/tx/cs/cs-mimetypes.js +198 -12
- package/tx/html/tx-template.html +1 -0
- package/tx/provider.js +1 -1
- package/tx/sct/expressions.js +34 -9
- package/tx/tx-html.js +107 -3
- package/tx/tx.js +44 -0
- package/tx/workers/validate.js +36 -13
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,33 @@ All notable changes to the Health Intersections Node Server will be documented i
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [v0.13.2] - 2026-09-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Add support for publishLibrarySource
|
|
13
|
+
-
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Fix error in Snomed CT and ICD-11 where normal form difference and case difference being conflated
|
|
17
|
+
- Fix error in mime-type subsumption analysis
|
|
18
|
+
- adjust mime type validation to not be case sensitive
|
|
19
|
+
|
|
20
|
+
### Tx Conformance Statement
|
|
21
|
+
|
|
22
|
+
FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.5-SNAPSHOT, runner v6.10.4)
|
|
23
|
+
|
|
24
|
+
## [v0.13.1] - 2026-09-05
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Error in ICD-11 provider that made the version page not show
|
|
29
|
+
|
|
30
|
+
### Tx Conformance Statement
|
|
31
|
+
|
|
32
|
+
FHIRsmith passed all 3489 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.4, runner v6.10.4)
|
|
33
|
+
|
|
34
|
+
## [v0.13.0] - 2026-09-04
|
|
9
35
|
|
|
10
36
|
### Added
|
|
11
37
|
|
|
@@ -75,7 +101,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
75
101
|
the tx-issue-type in `txIssueType`. Every catch block now goes through
|
|
76
102
|
`outcomeFromError()`, which is the one place that knows about both
|
|
77
103
|
|
|
78
|
-
|
|
104
|
+
### Tx Conformance Statement
|
|
105
|
+
|
|
106
|
+
FHIRsmith passed all 3489 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.4, runner v6.10.4)
|
|
107
|
+
|
|
108
|
+
## [v0.12.0] - 2026-08-27
|
|
79
109
|
|
|
80
110
|
### Added
|
|
81
111
|
|
|
@@ -380,7 +410,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
380
410
|
|
|
381
411
|
FHIRsmith passed all 2822 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.3, runner v6.10.3)
|
|
382
412
|
|
|
383
|
-
## [
|
|
413
|
+
## [v0.11.2] - 2026-08-12
|
|
384
414
|
|
|
385
415
|
### Fixed
|
|
386
416
|
|
|
@@ -395,7 +425,7 @@ FHIRsmith passed all 2822 HL7 terminology service tests (modes tx.fhir.org+omop+
|
|
|
395
425
|
|
|
396
426
|
FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.3, runner v6.10.1)
|
|
397
427
|
|
|
398
|
-
## [
|
|
428
|
+
## [v0.11.1] - 2026-07-30
|
|
399
429
|
|
|
400
430
|
### Added
|
|
401
431
|
|
|
@@ -429,7 +459,7 @@ FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+
|
|
|
429
459
|
|
|
430
460
|
FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.2, runner v6.9.12)
|
|
431
461
|
|
|
432
|
-
## [
|
|
462
|
+
## [v0.11.0] - 2026-mm-dd
|
|
433
463
|
|
|
434
464
|
### Added
|
|
435
465
|
|
package/config-template.json
CHANGED
|
@@ -150,6 +150,11 @@
|
|
|
150
150
|
"tx": {
|
|
151
151
|
"enabled": false,
|
|
152
152
|
"librarySource": "/absolute/path/to/library.yml",
|
|
153
|
+
// publish the library source above at <endpoint>/library, so users can see what this
|
|
154
|
+
// server actually loads. One URL, content negotiated: a browser gets a formatted page
|
|
155
|
+
// (and a "Library" item in the tx nav bar), anything else gets the YAML file itself.
|
|
156
|
+
// Off by default - the file names every database, cache and package the server runs.
|
|
157
|
+
"publishLibrarySource": false,
|
|
153
158
|
"cacheTimeout": 30,
|
|
154
159
|
"expansionCacheSize": 1000,
|
|
155
160
|
"expansionCacheMemoryThreshold": 0,
|
package/library/html-server.js
CHANGED
|
@@ -80,7 +80,9 @@ class HtmlServer {
|
|
|
80
80
|
.replace(/\[%fhir-version%\]/g, escape(renderOptions.fhirversion))
|
|
81
81
|
.replace(/\[%ms%\]/g, escape(renderOptions.processingTime.toString()))
|
|
82
82
|
.replace(/\[%sponsorMessage%\]/g, sponsorMessage)
|
|
83
|
-
.replace(/\[%about%\]/g, renderOptions.about || '')
|
|
83
|
+
.replace(/\[%about%\]/g, renderOptions.about || '')
|
|
84
|
+
// raw HTML, like [%about%]: the caller decides whether the nav item exists at all
|
|
85
|
+
.replace(/\[%library-link%\]/g, renderOptions.libraryLink || '');
|
|
84
86
|
|
|
85
87
|
// Handle any custom template variables
|
|
86
88
|
if (options.templateVars) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.13.
|
|
4
|
-
"txVersion": "1.9.
|
|
3
|
+
"version": "0.13.2",
|
|
4
|
+
"txVersion": "1.9.5-SNAPSHOT",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
7
7
|
"engines": {
|
|
@@ -97,6 +97,7 @@ CDA_UNKNOWN_TEMPLATE_EXT = The CDA Template {0} / {1} is not known
|
|
|
97
97
|
CDATA_is_not_allowed = CDATA is not allowed
|
|
98
98
|
Circular_snapshot_references_detected_cannot_generate_snapshot_stack__ = Circular snapshot references detected; cannot generate snapshot (stack = {0})
|
|
99
99
|
CODE_CASE_DIFFERENCE = The code ''{0}'' differs from the correct code ''{1}'' by case. Although the code system ''{2}'' is case insensitive, implementers are strongly encouraged to use the correct case anyway
|
|
100
|
+
CODE_NOT_IN_NORMAL_FORM = The code ''{0}'' is valid, but it is not the normal form for the code system ''{2}''; the normal form is ''{1}''
|
|
100
101
|
Code_found_in_expansion_however_ = Code found in expansion, however: {0}
|
|
101
102
|
CODESYSTEM_CONCEPT_NO_DEFINITION = HL7 Defined CodeSystems should ensure that every concept has a definition
|
|
102
103
|
CODESYSTEM_CONCEPT_NO_DISPLAY = HL7 Defined CodeSystems should ensure that every concept has a display
|
package/tx/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Add the `tx` section to your `config.json`:
|
|
|
25
25
|
"tx": {
|
|
26
26
|
"enabled": true,
|
|
27
27
|
"librarySource": "/path/to/library.yml",
|
|
28
|
+
"publishLibrarySource": false,
|
|
28
29
|
"cacheTimeout": 30,
|
|
29
30
|
"internalLimit" : 10000,
|
|
30
31
|
"externalLimit" : 1000,
|
|
@@ -66,6 +67,7 @@ Add the `tx` section to your `config.json`:
|
|
|
66
67
|
| `expansionCacheSize` | integer | No | Maximum number of expanded ValueSets to cache. Default: 1000 |
|
|
67
68
|
| `expansionCacheMemoryThreshold` | integer | No | Heap memory usage in MB that triggers evicting oldest half of expansion cache. 0 = disabled. Default: 0 |
|
|
68
69
|
| `librarySource` | string | Yes | Path to the YAML file that defines the terminology sources to load |
|
|
70
|
+
| `publishLibrarySource` | boolean | No | Publish that YAML file at `/{path}/library`. Default: false |
|
|
69
71
|
| `internalLimit` | integer | No | Largest number of codes in internal expansions |
|
|
70
72
|
| `externalLimit` | integer | No | Largest number of codes the server will return in an expansion |
|
|
71
73
|
| `endpoints` | array | Yes | List of endpoint configurations (at least one required) |
|
|
@@ -125,6 +127,26 @@ Each endpoint provides the following FHIR terminology operations:
|
|
|
125
127
|
Each endpoint also provides:
|
|
126
128
|
- `GET /{path}/metadata` - Returns a CapabilityStatement describing the endpoint's capabilities
|
|
127
129
|
- `GET /{path}/` - Returns basic endpoint information
|
|
130
|
+
- `GET /{path}/library` - Returns the library source YAML, if `publishLibrarySource` is set
|
|
131
|
+
|
|
132
|
+
### Publishing the library source
|
|
133
|
+
|
|
134
|
+
`publishLibrarySource` lets users see what the server actually loads. It is off by default,
|
|
135
|
+
because the library YAML names every database, cache and package the server runs, and not
|
|
136
|
+
every deployment wants that public. Turn it on and each endpoint serves the file at
|
|
137
|
+
`/{path}/library`, and a **Library** item appears in the tx navigation bar.
|
|
138
|
+
|
|
139
|
+
There is one URL, content negotiated the same way the rest of the module negotiates:
|
|
140
|
+
|
|
141
|
+
| Request | Response |
|
|
142
|
+
|---------|----------|
|
|
143
|
+
| `Accept: text/html` (i.e. a browser) | the file, syntax highlighted, in the tx page template |
|
|
144
|
+
| anything else | the file itself, as `application/yaml` |
|
|
145
|
+
| `?_format=html` / `?_format=yaml` | forces either representation |
|
|
146
|
+
|
|
147
|
+
The file is read from disk per request, so editing it is reflected immediately - note that
|
|
148
|
+
this means the published file can be ahead of what the running server actually loaded, if it
|
|
149
|
+
has been edited since startup.
|
|
128
150
|
|
|
129
151
|
## Library Configuration
|
|
130
152
|
|
package/tx/cs/cs-cs.js
CHANGED
|
@@ -1695,6 +1695,19 @@ class FhirCodeSystemFactory extends CodeSystemFactoryProvider {
|
|
|
1695
1695
|
return "cs";
|
|
1696
1696
|
}
|
|
1697
1697
|
|
|
1698
|
+
/**
|
|
1699
|
+
* This factory wraps whatever CodeSystem it is handed at build() time, so it has no
|
|
1700
|
+
* system or version of its own. It is never registered in codeSystemFactories (which
|
|
1701
|
+
* is keyed on system()); these answer for the abstract contract, not for a lookup.
|
|
1702
|
+
*/
|
|
1703
|
+
system() {
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
version() {
|
|
1708
|
+
return null;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1698
1711
|
}
|
|
1699
1712
|
|
|
1700
1713
|
module.exports = {
|
package/tx/cs/cs-icd11.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* Foundation -- so the factory is constructed per system and several factories share one
|
|
7
7
|
* file. `tx/library.js` registers one per system found in the database.
|
|
8
8
|
*
|
|
9
|
-
* STATUS: stage 1. Metadata, code location, displays, definitions, designations,
|
|
10
|
-
* properties and extendLookup work. Filters, iteration, subsumption and postcoordinated
|
|
11
|
-
* expressions are still to come; each returns the safe empty answer and is marked below.
|
|
12
9
|
* The tx-ecosystem `icd-11` test suite is the specification -- it was written to assert
|
|
13
|
-
* correct behaviour,
|
|
10
|
+
* correct behaviour, and it is what these are tested against; there is no unit test file
|
|
11
|
+
* of their own. Metadata, code location, displays, definitions, designations, properties,
|
|
12
|
+
* extendLookup, filters, iteration, subsumption and postcoordinated expressions are all
|
|
13
|
+
* implemented.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const sqlite3 = require('sqlite3').verbose();
|
|
@@ -119,6 +119,25 @@ class ICD11Services extends BaseCSServices {
|
|
|
119
119
|
totalCount() { return this.shared.count; }
|
|
120
120
|
defLang() { return 'en'; }
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* WHO publishes the classification in the languages the database was crawled with, so
|
|
124
|
+
* the base class's English-only assumption is wrong here: it decides whether a failed
|
|
125
|
+
* display check says "no display in that language" or "none at all".
|
|
126
|
+
*/
|
|
127
|
+
hasAnyDisplays(languages) {
|
|
128
|
+
const langs = this._ensureLanguages(languages);
|
|
129
|
+
for (const l of langs) {
|
|
130
|
+
const base = l.language || String(l.code || '').split('-')[0];
|
|
131
|
+
if (base && this.shared.languages.includes(base)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return super.hasAnyDisplays(langs);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Versions are WHO release ids -- '2026-01'. */
|
|
139
|
+
versionAlgorithm() { return 'date'; }
|
|
140
|
+
|
|
122
141
|
/**
|
|
123
142
|
* The WHO CodeSystem says caseSensitive: false, but its own $validate-code rejects
|
|
124
143
|
* '1a00'. The codes are case-sensitive tokens; the declaration is the part that is
|
|
@@ -285,13 +304,17 @@ class ICD11Services extends BaseCSServices {
|
|
|
285
304
|
return row ? (row.code || row.uri) : null;
|
|
286
305
|
}
|
|
287
306
|
|
|
288
|
-
/**
|
|
307
|
+
/**
|
|
308
|
+
* The best language for display, given what the request asked for. Languages is an
|
|
309
|
+
* iterable of Language, in quality order; the database holds translations keyed on the
|
|
310
|
+
* primary subtag, so that is what is matched.
|
|
311
|
+
*/
|
|
289
312
|
#displayLang() {
|
|
290
313
|
const langs = this.opContext?.langs;
|
|
291
314
|
if (langs) {
|
|
292
|
-
for (const l of
|
|
293
|
-
const base = String(l).split('-')[0];
|
|
294
|
-
if (this.shared.languages.includes(base)) return base;
|
|
315
|
+
for (const l of langs) {
|
|
316
|
+
const base = l.language || String(l.code || '').split('-')[0];
|
|
317
|
+
if (base && this.shared.languages.includes(base)) return base;
|
|
295
318
|
}
|
|
296
319
|
}
|
|
297
320
|
return this.defLang();
|
|
@@ -698,6 +721,22 @@ class ICD11ServicesFactory extends CodeSystemFactoryProvider {
|
|
|
698
721
|
version() { return this._shared?.version; }
|
|
699
722
|
defaultVersion() { return this._shared?.version; }
|
|
700
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Short label for the home page and for code system links. Deliberately not the WHO
|
|
726
|
+
* title from the database (which the provider uses): this has to answer before load(),
|
|
727
|
+
* since the home page sorts factories by name.
|
|
728
|
+
*/
|
|
729
|
+
name() {
|
|
730
|
+
switch (this.systemCode) {
|
|
731
|
+
case 'mms': return 'ICD-11 MMS';
|
|
732
|
+
case 'icf': return 'ICD-11 ICF';
|
|
733
|
+
case 'foundation': return 'ICD-11 Foundation';
|
|
734
|
+
default: return `ICD-11 ${this.systemCode}`;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
id() { return `icd11-${this.systemCode}`; }
|
|
739
|
+
|
|
701
740
|
async load() {
|
|
702
741
|
if (this._loaded) return;
|
|
703
742
|
const db = new sqlite3.Database(this.dbPath, sqlite3.OPEN_READONLY);
|
package/tx/cs/cs-mimetypes.js
CHANGED
|
@@ -36,6 +36,99 @@ const { Issue } = require("../library/operation-outcome");
|
|
|
36
36
|
*/
|
|
37
37
|
const UNDERSTOOD_PARAMETERS = new Set(['charset', 'format', 'delsp', 'version']);
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Parameters whose VALUES are case-insensitive.
|
|
41
|
+
*
|
|
42
|
+
* RFC 2045 section 5.1 makes type, subtype and parameter names case-insensitive, and then says
|
|
43
|
+
* of values: "Parameter values are normally case sensitive, but sometimes are interpreted in a
|
|
44
|
+
* case-insensitive fashion, depending on the intended use." So a value is only folded where its
|
|
45
|
+
* own definition says to fold it, and this is the list of those we know of:
|
|
46
|
+
*
|
|
47
|
+
* - charset RFC 2046 section 4.1.2: "Unlike some other parameter values, the values of the
|
|
48
|
+
* charset parameter are NOT case sensitive." (RFC 9110 section 8.3.2 agrees.)
|
|
49
|
+
* - format RFC 3676 section 4, of Format and DelSp together: "(Neither the parameter names
|
|
50
|
+
* - delsp nor values are case sensitive.)"
|
|
51
|
+
*
|
|
52
|
+
* `version` is deliberately absent: there is no general definition of a version parameter -
|
|
53
|
+
* it exists only per-registration (text/vcard has one, text/html deliberately removed one) -
|
|
54
|
+
* so there is nothing that says its values fold, and folding them could equate two versions
|
|
55
|
+
* that a registration means to distinguish.
|
|
56
|
+
*
|
|
57
|
+
* @type {Set<string>}
|
|
58
|
+
*/
|
|
59
|
+
const CASE_INSENSITIVE_VALUES = new Set(['charset', 'format', 'delsp']);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Media types whose registration gives a parameter a DEFAULT value.
|
|
63
|
+
*
|
|
64
|
+
* This is the thing that breaks the otherwise tidy rule that a parameter narrows a media type.
|
|
65
|
+
* Where a parameter has a default, the bare form is not the general case - it already carries
|
|
66
|
+
* that value - so `text/plain` does not subsume `text/plain; charset=utf-8`, the two are
|
|
67
|
+
* different things. Applying the default to whichever side omits it, and then comparing as
|
|
68
|
+
* usual, gets this right in both directions: it also makes `text/plain` and
|
|
69
|
+
* `text/plain; charset=us-ascii` come out equivalent, which they are and which we used to
|
|
70
|
+
* get wrong in the other direction.
|
|
71
|
+
*
|
|
72
|
+
* There is no list to load this from. IANA's registry schema has no parameter element at all -
|
|
73
|
+
* a record is a name, a deprecation flag, cross-references and a pointer to a template - and
|
|
74
|
+
* defaults live only in the prose of each defining RFC. `text/plain`, the type that needs this
|
|
75
|
+
* most, has no IANA registration template whatsoever ("No registration template available"),
|
|
76
|
+
* so all of the below comes from the RFCs directly:
|
|
77
|
+
*
|
|
78
|
+
* - text/plain charset: RFC 2046 section 4.1.2 made US-ASCII the default, and RFC 6657
|
|
79
|
+
* section 4 exists specifically to say it stayed: "The default "charset" parameter value
|
|
80
|
+
* for "text/plain" is unchanged from [RFC2046] and remains as "US-ASCII"."
|
|
81
|
+
* - text/plain format and delsp: RFC 3676 section 4, "If Format is not specified, or if the
|
|
82
|
+
* value is not recognized, a value of Fixed is assumed" and "If DelSp is not specified, or
|
|
83
|
+
* if its value is not recognized, a value of No is assumed."
|
|
84
|
+
* - text/vcard charset: RFC 6350 section 3.1, "The charset ... for vCard is UTF-8 ... There is
|
|
85
|
+
* no way to override this." A default expressed as a prohibition, but a default.
|
|
86
|
+
*
|
|
87
|
+
* Keyed by lower-case type/subtype; the inner map is lower-case parameter name to the value
|
|
88
|
+
* the bare form implies. Values are stored folded, matching CASE_INSENSITIVE_VALUES.
|
|
89
|
+
*
|
|
90
|
+
* @type {Map<string, Map<string, string>>}
|
|
91
|
+
*/
|
|
92
|
+
const PARAMETER_DEFAULTS = new Map([
|
|
93
|
+
['text/plain', new Map([['charset', 'us-ascii'], ['format', 'fixed'], ['delsp', 'no']])],
|
|
94
|
+
['text/vcard', new Map([['charset', 'utf-8']])]
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* text/* types whose registration says explicitly that charset has NO default.
|
|
99
|
+
*
|
|
100
|
+
* RFC 6657 section 3 left a residual rule behind that is the awkward part of all this:
|
|
101
|
+
* "existing "text/*" registrations that fail to specify how the charset is determined still
|
|
102
|
+
* default to US-ASCII." So for a text/* type, silence in the registration is not the absence
|
|
103
|
+
* of a default - it IS a default, of US-ASCII. That set cannot be enumerated without reading
|
|
104
|
+
* every legacy text/* registration and judging whether it "clearly specifies", so instead of
|
|
105
|
+
* guessing, charset on an unlisted text/* type yields cannot-determine.
|
|
106
|
+
*
|
|
107
|
+
* This set is the escape hatch: text/* types we have actually read, which say there is no
|
|
108
|
+
* default, and for which the narrowing rule therefore holds.
|
|
109
|
+
*
|
|
110
|
+
* - text/markdown: RFC 7763 section 2, "charset: Per Section 4.2.1 of [RFC6838], charset is
|
|
111
|
+
* REQUIRED. There is no default value because neither [MDSYNTAX] nor many popular
|
|
112
|
+
* implementations at the time of this registration do either."
|
|
113
|
+
* - text/html: a judgement rather than a quoted default, and the only entry here that is.
|
|
114
|
+
* Neither registration states one - RFC 2854 section 6 recites the RFC 2046 and RFC 2616
|
|
115
|
+
* rules, observes that they conflict, and only recommends being explicit; the current IANA
|
|
116
|
+
* registration is the W3C one, whose whole optional-parameters text is that charset
|
|
117
|
+
* "may be provided to definitively specify the document's character encoding, overriding
|
|
118
|
+
* any character encoding declarations in the document". HTML determines its encoding in
|
|
119
|
+
* band, and the phrase "overriding any character encoding declarations in the document"
|
|
120
|
+
* only makes sense if the absence of the parameter means "whatever the document says"
|
|
121
|
+
* rather than "US-ASCII". Listed because text/html is the most asked-about media type
|
|
122
|
+
* there is, and declining to answer for it is a poor trade for a defensible reading.
|
|
123
|
+
*
|
|
124
|
+
* Non-text types need no entry: RFC 6657 is a text/* rule, so silence elsewhere really is
|
|
125
|
+
* silence (application/xml, for instance - RFC 7303 section 3.2 tells consumers they "SHOULD
|
|
126
|
+
* NOT assume a default encoding").
|
|
127
|
+
*
|
|
128
|
+
* @type {Set<string>}
|
|
129
|
+
*/
|
|
130
|
+
const TEXT_TYPES_WITH_NO_CHARSET_DEFAULT = new Set(['text/markdown', 'text/html']);
|
|
131
|
+
|
|
39
132
|
// The IANA media types registry. Only the names are kept - the registry is a few MB of
|
|
40
133
|
// cross-references and templates, and all the `registered` filter asks is whether a type is in it
|
|
41
134
|
const IANA_MEDIA_TYPES_URL = 'https://www.iana.org/assignments/media-types/media-types.xml';
|
|
@@ -46,6 +139,63 @@ class MimeTypeConcept {
|
|
|
46
139
|
constructor(code) {
|
|
47
140
|
this.code = code;
|
|
48
141
|
this.mimeType = this.#parseMimeType(code);
|
|
142
|
+
this.normalized = this.#normalize(code);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The canonical spelling of this media type.
|
|
147
|
+
*
|
|
148
|
+
* Four things are canonicalised, and each is something the specs say carries no meaning:
|
|
149
|
+
*
|
|
150
|
+
* - case, in the type, the subtype, every parameter name, and the values of the parameters
|
|
151
|
+
* in CASE_INSENSITIVE_VALUES (RFC 2045 section 5.1, RFC 2046 section 4.1.2, RFC 3676
|
|
152
|
+
* section 4). Every other parameter value keeps its case: RFC 2045 makes values
|
|
153
|
+
* case-sensitive unless their own definition says otherwise, and a boundary or a profile
|
|
154
|
+
* URI can genuinely differ by case
|
|
155
|
+
* - parameter ORDER, which is not significant, so sorting by name gives two spellings of one
|
|
156
|
+
* media type one spelling. Without this, `text/plain; charset=utf-8; format=flowed` and
|
|
157
|
+
* `text/plain; format=flowed; charset=utf-8` would have two different "normal" forms,
|
|
158
|
+
* which is not a normal form at all
|
|
159
|
+
* - the separator, which becomes '; '
|
|
160
|
+
* - surrounding and internal padding
|
|
161
|
+
*
|
|
162
|
+
* Quoting is NOT canonicalised. A quoted value can always be written quoted, so removing the
|
|
163
|
+
* quotes needs to know that the value contains no tspecials, and getting that wrong produces
|
|
164
|
+
* a "canonical" form that is not a legal media type. Left for someone who needs it.
|
|
165
|
+
*
|
|
166
|
+
* Note the consequence for validation: reordering or respacing makes this differ from the
|
|
167
|
+
* submitted code by more than case, so it is reported as a normal-form difference rather
|
|
168
|
+
* than a case difference. That is the honest description of what changed.
|
|
169
|
+
*/
|
|
170
|
+
#normalize(code) {
|
|
171
|
+
if (!this.mimeType.isValid) {
|
|
172
|
+
return code;
|
|
173
|
+
}
|
|
174
|
+
const segments = code.split(';');
|
|
175
|
+
const params = [];
|
|
176
|
+
for (const segment of segments.slice(1)) {
|
|
177
|
+
const trimmed = segment.trim();
|
|
178
|
+
if (trimmed === '') {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const eq = trimmed.indexOf('=');
|
|
182
|
+
if (eq === -1) {
|
|
183
|
+
// not a name=value pair. The parser ignores it, but dropping it here would make the
|
|
184
|
+
// canonical form say something the submitted code did not, so it is carried through -
|
|
185
|
+
// untouched, since only parameter NAMES fold and nothing says this is one
|
|
186
|
+
params.push({ name: trimmed.toLowerCase(), text: trimmed });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const name = trimmed.slice(0, eq).trim().toLowerCase();
|
|
190
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
191
|
+
params.push({
|
|
192
|
+
name: name,
|
|
193
|
+
text: name + '=' + (CASE_INSENSITIVE_VALUES.has(name) ? value.toLowerCase() : value)
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// by name, then by the whole parameter, so a repeated name is still ordered predictably
|
|
197
|
+
params.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : (a.text < b.text ? -1 : a.text > b.text ? 1 : 0)));
|
|
198
|
+
return [segments[0].trim().toLowerCase(), ...params.map(p => p.text)].join('; ');
|
|
49
199
|
}
|
|
50
200
|
|
|
51
201
|
#parseMimeType(code) {
|
|
@@ -71,7 +221,7 @@ class MimeTypeConcept {
|
|
|
71
221
|
if (value.length > 1 && value.startsWith('"') && value.endsWith('"')) {
|
|
72
222
|
value = value.slice(1, -1);
|
|
73
223
|
}
|
|
74
|
-
if (name
|
|
224
|
+
if (CASE_INSENSITIVE_VALUES.has(name)) {
|
|
75
225
|
value = value.toLowerCase();
|
|
76
226
|
}
|
|
77
227
|
if (name) {
|
|
@@ -196,7 +346,7 @@ class MimeTypeServices extends CodeSystemProvider {
|
|
|
196
346
|
async code(code) {
|
|
197
347
|
|
|
198
348
|
const ctxt = await this.#ensureContext(code);
|
|
199
|
-
return ctxt ? ctxt.
|
|
349
|
+
return ctxt ? ctxt.normalized : null;
|
|
200
350
|
}
|
|
201
351
|
|
|
202
352
|
async display(code) {
|
|
@@ -206,14 +356,15 @@ class MimeTypeServices extends CodeSystemProvider {
|
|
|
206
356
|
return null;
|
|
207
357
|
}
|
|
208
358
|
|
|
209
|
-
// Check supplements first
|
|
210
|
-
|
|
359
|
+
// Check supplements first, by the normalised code - a supplement that designates
|
|
360
|
+
// 'text/plain' has to be found for a request that spells it 'TEXT/Plain'
|
|
361
|
+
const suppDisplay = this._displayFromSupplements(ctxt.normalized);
|
|
211
362
|
if (suppDisplay) {
|
|
212
363
|
return suppDisplay;
|
|
213
364
|
}
|
|
214
365
|
|
|
215
|
-
// Default display is the code itself, trimmed
|
|
216
|
-
return ctxt.
|
|
366
|
+
// Default display is the code itself, normalised and trimmed
|
|
367
|
+
return ctxt.normalized.trim();
|
|
217
368
|
}
|
|
218
369
|
|
|
219
370
|
async definition(code) {
|
|
@@ -314,11 +465,27 @@ class MimeTypeServices extends CodeSystemProvider {
|
|
|
314
465
|
return 'not-subsumed';
|
|
315
466
|
}
|
|
316
467
|
|
|
468
|
+
// Where the registration gives a parameter a default, the bare form already carries that
|
|
469
|
+
// value, so fill it in on both sides before comparing anything. After this, a parameter
|
|
470
|
+
// that defaults is present on both sides and the narrowing logic below never sees it as
|
|
471
|
+
// "added" - which is the point: adding format=flowed to text/plain does not narrow it, it
|
|
472
|
+
// contradicts the format=fixed the bare form meant all along.
|
|
473
|
+
const key = a.type + '/' + a.subtype;
|
|
474
|
+
const defaults = PARAMETER_DEFAULTS.get(key);
|
|
475
|
+
const aParams = new Map(a.params);
|
|
476
|
+
const bParams = new Map(b.params);
|
|
477
|
+
if (defaults) {
|
|
478
|
+
for (const [name, value] of defaults) {
|
|
479
|
+
if (!aParams.has(name)) aParams.set(name, value);
|
|
480
|
+
if (!bParams.has(name)) bParams.set(name, value);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
317
484
|
// Only the parameters the two codes DISAGREE on can decide the answer - one present
|
|
318
485
|
// on one side only, or present on both with different values. A parameter carried
|
|
319
486
|
// identically by both, understood or not, cannot affect the result and is ignored.
|
|
320
|
-
const deciding = [...new Set([...
|
|
321
|
-
.filter(name =>
|
|
487
|
+
const deciding = [...new Set([...aParams.keys(), ...bParams.keys()])]
|
|
488
|
+
.filter(name => aParams.get(name) !== bParams.get(name));
|
|
322
489
|
const unknown = deciding.filter(name => !UNDERSTOOD_PARAMETERS.has(name));
|
|
323
490
|
if (unknown.length > 0) {
|
|
324
491
|
throw cannotDetermineSubsumption(
|
|
@@ -331,15 +498,34 @@ class MimeTypeServices extends CodeSystemProvider {
|
|
|
331
498
|
+ 'that may be wrong');
|
|
332
499
|
}
|
|
333
500
|
|
|
501
|
+
// RFC 6657 section 3: a text/* registration that does not say how the charset is determined
|
|
502
|
+
// still defaults to US-ASCII. So on a text/* type we have not read, we do not know whether
|
|
503
|
+
// the side without a charset means "any charset" or "US-ASCII", and that is exactly the
|
|
504
|
+
// difference between subsumes and not-subsumed. Only bites when charset is present on one
|
|
505
|
+
// side and absent on the other - two explicit values are just two values.
|
|
506
|
+
if (deciding.includes('charset') && a.type === 'text'
|
|
507
|
+
&& !(defaults && defaults.has('charset'))
|
|
508
|
+
&& !TEXT_TYPES_WITH_NO_CHARSET_DEFAULT.has(key)
|
|
509
|
+
&& aParams.has('charset') !== bParams.has('charset')) {
|
|
510
|
+
throw cannotDetermineSubsumption(
|
|
511
|
+
`Unable to determine the subsumption relationship between '${a.source}' and `
|
|
512
|
+
+ `'${b.source}': one carries a charset and the other does not, and this server does `
|
|
513
|
+
+ `not know whether '${key}' gives charset a default value. RFC 6657 leaves every `
|
|
514
|
+
+ 'text/* registration that does not say how the charset is determined defaulting to '
|
|
515
|
+
+ 'US-ASCII, so the absence of a charset may mean "any charset" or may mean "US-ASCII", '
|
|
516
|
+
+ 'and those give opposite answers. No outcome is returned rather than one that may be '
|
|
517
|
+
+ 'wrong');
|
|
518
|
+
}
|
|
519
|
+
|
|
334
520
|
// Any parameter they both carry has to agree, or neither includes the other
|
|
335
|
-
for (const [name, value] of
|
|
336
|
-
if (
|
|
521
|
+
for (const [name, value] of aParams) {
|
|
522
|
+
if (bParams.has(name) && bParams.get(name) !== value) {
|
|
337
523
|
return 'not-subsumed';
|
|
338
524
|
}
|
|
339
525
|
}
|
|
340
526
|
|
|
341
|
-
const aInB = [...
|
|
342
|
-
const bInA = [...
|
|
527
|
+
const aInB = [...aParams.keys()].every(name => bParams.has(name));
|
|
528
|
+
const bInA = [...bParams.keys()].every(name => aParams.has(name));
|
|
343
529
|
|
|
344
530
|
if (aInB && bInA) {
|
|
345
531
|
return 'equivalent';
|
package/tx/html/tx-template.html
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
<a href="[%endpoint-path%]/metadata?mode=terminology" style="color: gold">Terminology Capabilities</a> |
|
|
91
91
|
<a href="[%endpoint-path%]/op.html" style="color: gold">Operations</a> |
|
|
92
92
|
<a href="[%endpoint-path%]/problems.html" style="color: gold">Problems</a> |
|
|
93
|
+
[%library-link%]
|
|
93
94
|
|
|
94
95
|
</div>
|
|
95
96
|
</div>
|
package/tx/provider.js
CHANGED
package/tx/sct/expressions.js
CHANGED
|
@@ -537,18 +537,40 @@ class SnomedExpressionParser {
|
|
|
537
537
|
|
|
538
538
|
/**
|
|
539
539
|
* Parse refinements for an expression
|
|
540
|
+
*
|
|
541
|
+
* The compositional grammar ABNF is
|
|
542
|
+
* refinement = (attributeSet / attributeGroup) *( ws ["," ws] attributeGroup )
|
|
543
|
+
* attributeSet = attribute *(ws "," ws attribute)
|
|
544
|
+
* so a comma is required between the ungrouped attributes of the attribute set, but is
|
|
545
|
+
* OPTIONAL before an attribute group. Requiring it everywhere rejected the minimal
|
|
546
|
+
* rendering this parser's own renderer produces (e.g. 249943000:260868000=6934004{...}).
|
|
547
|
+
*
|
|
548
|
+
* We stay liberal in what we accept: a group may also be followed by further ungrouped
|
|
549
|
+
* attributes, which the ABNF does not allow but which this parser has always taken.
|
|
540
550
|
*/
|
|
541
551
|
refinements(expr) {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
552
|
+
this.ws();
|
|
553
|
+
|
|
554
|
+
let first = true;
|
|
555
|
+
for (;;) {
|
|
556
|
+
if (!first) {
|
|
557
|
+
const mark = this.cursor;
|
|
558
|
+
const comma = this.gchar(',');
|
|
559
|
+
this.ws();
|
|
560
|
+
if (this.peek() !== '{' && !comma) {
|
|
561
|
+
// neither a separating comma nor a group: the refinements are finished
|
|
562
|
+
this.cursor = mark;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (this.peek() === '{') {
|
|
547
568
|
expr.refinementGroups.push(this.attributeGroup());
|
|
569
|
+
} else {
|
|
570
|
+
expr.refinements.push(this.attribute());
|
|
548
571
|
}
|
|
549
572
|
this.ws();
|
|
550
|
-
|
|
551
|
-
this.ws();
|
|
573
|
+
first = false;
|
|
552
574
|
}
|
|
553
575
|
}
|
|
554
576
|
|
|
@@ -2444,10 +2466,13 @@ class SnomedExpressionServices {
|
|
|
2444
2466
|
}
|
|
2445
2467
|
}
|
|
2446
2468
|
|
|
2447
|
-
// Grouped refinements
|
|
2469
|
+
// Grouped refinements. The comma between the attribute set and the first group is
|
|
2470
|
+
// optional in the grammar, but we emit it: it is what makes the boundary between the
|
|
2471
|
+
// ungrouped attributes and the first group legible, and it is the safer of the two
|
|
2472
|
+
// spellings to hand to a third-party parser.
|
|
2448
2473
|
if (expr.hasRefinementGroups()) {
|
|
2449
2474
|
for (let j = 0; j < expr.refinementGroups.length; j++) {
|
|
2450
|
-
if (j > 0) parts.push(',');
|
|
2475
|
+
if (j > 0 || expr.hasRefinements()) parts.push(',');
|
|
2451
2476
|
parts.push('{');
|
|
2452
2477
|
|
|
2453
2478
|
for (let i = 0; i < expr.refinementGroups[j].refinements.length; i++) {
|
package/tx/tx-html.js
CHANGED
|
@@ -86,6 +86,99 @@ function acceptsHtml(req) {
|
|
|
86
86
|
return _fmt.includes('text/html');
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Whether this server publishes its library source YAML. Off unless the operator turns
|
|
90
|
+
// modules.tx.publishLibrarySource on, and it gates both the route and the nav item, so a
|
|
91
|
+
// server that does not publish it has no link to a 404.
|
|
92
|
+
let publishLibrarySource = false;
|
|
93
|
+
|
|
94
|
+
function setPublishLibrarySource(enabled) {
|
|
95
|
+
publishLibrarySource = enabled === true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function publishesLibrarySource() {
|
|
99
|
+
return publishLibrarySource;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Highlight a YAML document for display.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately line-oriented and modest: this is the operator's own source file, not a
|
|
106
|
+
* general YAML parser, and it has to survive anything a hand-edited file contains without
|
|
107
|
+
* ever emitting the file's bytes unescaped. Each line is split into its parts FIRST and
|
|
108
|
+
* every part is escaped before any markup goes near it, so no amount of angle brackets or
|
|
109
|
+
* ampersands in a value can break out.
|
|
110
|
+
*/
|
|
111
|
+
function highlightYaml(source) {
|
|
112
|
+
const lines = String(source).replace(/\r\n/g, '\n').split('\n');
|
|
113
|
+
const out = lines.map(line => {
|
|
114
|
+
if (line.trim() === '') {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
// a whole-line comment
|
|
118
|
+
const wholeComment = /^(\s*)(#.*)$/.exec(line);
|
|
119
|
+
if (wholeComment) {
|
|
120
|
+
return escape(wholeComment[1]) + '<span class="y-c">' + escape(wholeComment[2]) + '</span>';
|
|
121
|
+
}
|
|
122
|
+
// split off a trailing comment, but only one introduced by whitespace-then-#, so a
|
|
123
|
+
// '#' inside a value (a FHIR fragment URL, say) stays part of the value
|
|
124
|
+
let body = line;
|
|
125
|
+
let comment = '';
|
|
126
|
+
const trailing = /^(.*?)(\s+#.*)$/.exec(line);
|
|
127
|
+
if (trailing) {
|
|
128
|
+
body = trailing[1];
|
|
129
|
+
comment = '<span class="y-c">' + escape(trailing[2]) + '</span>';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// a mapping key needs whitespace (or end of line) after the colon - without it the
|
|
133
|
+
// colon is just part of a scalar, which is exactly what a source line like
|
|
134
|
+
// '- internal:lang' or '- snomed!:sct_intl_20250201.cache' is
|
|
135
|
+
const m = /^(\s*)(-\s+)?([A-Za-z0-9_.$-]+)(:)(\s+|$)(.*)$/.exec(body);
|
|
136
|
+
if (m) {
|
|
137
|
+
return escape(m[1])
|
|
138
|
+
+ (m[2] ? '<span class="y-d">' + escape(m[2]) + '</span>' : '')
|
|
139
|
+
+ '<span class="y-k">' + escape(m[3]) + '</span>'
|
|
140
|
+
+ '<span class="y-p">' + escape(m[4]) + '</span>'
|
|
141
|
+
+ escape(m[5])
|
|
142
|
+
+ (m[6] ? '<span class="y-v">' + escape(m[6]) + '</span>' : '')
|
|
143
|
+
+ comment;
|
|
144
|
+
}
|
|
145
|
+
const item = /^(\s*)(-\s+)(.*)$/.exec(body);
|
|
146
|
+
if (item) {
|
|
147
|
+
return escape(item[1])
|
|
148
|
+
+ '<span class="y-d">' + escape(item[2]) + '</span>'
|
|
149
|
+
+ '<span class="y-v">' + escape(item[3]) + '</span>'
|
|
150
|
+
+ comment;
|
|
151
|
+
}
|
|
152
|
+
return escape(body) + comment;
|
|
153
|
+
});
|
|
154
|
+
return out.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const YAML_STYLE = '<style>' +
|
|
158
|
+
'.yaml-source { background: #f7f7f7; border: 1px solid #ddd; padding: 12px; overflow-x: auto; ' +
|
|
159
|
+
'font-family: Menlo, Consolas, monospace; font-size: 90%; line-height: 1.45; }' +
|
|
160
|
+
'.yaml-source .y-k { color: #0b5394; font-weight: bold; }' +
|
|
161
|
+
'.yaml-source .y-v { color: #444; }' +
|
|
162
|
+
'.yaml-source .y-p, .yaml-source .y-d { color: #999; }' +
|
|
163
|
+
'.yaml-source .y-c { color: #777; font-style: italic; }' +
|
|
164
|
+
'</style>';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The page body for the library source: what the file is, where to get the raw bytes, and
|
|
168
|
+
* the file itself.
|
|
169
|
+
*/
|
|
170
|
+
function buildLibrarySourcePage(source, filename, endpointPath) {
|
|
171
|
+
return YAML_STYLE
|
|
172
|
+
+ '<p>This is the library source this server loads its content from - the code system files, '
|
|
173
|
+
+ 'the packages, and the terminology sources it makes available. It is shown here as it is on '
|
|
174
|
+
+ 'disk.</p>'
|
|
175
|
+
+ '<p>The same URL returns the file itself to anything that does not ask for HTML: '
|
|
176
|
+
+ '<code>curl -H "Accept: application/yaml" ' + escape(endpointPath) + '/library</code>, or '
|
|
177
|
+
+ '<a href="library?_format=yaml">' + escape(endpointPath) + '/library?_format=yaml</a>.</p>'
|
|
178
|
+
+ '<p>Source file: <code>' + escape(filename) + '</code></p>'
|
|
179
|
+
+ '<pre class="yaml-source">' + highlightYaml(source) + '</pre>';
|
|
180
|
+
}
|
|
181
|
+
|
|
89
182
|
|
|
90
183
|
class TxHtmlRenderer {
|
|
91
184
|
renderer;
|
|
@@ -110,7 +203,10 @@ class TxHtmlRenderer {
|
|
|
110
203
|
version: packageJson.version,
|
|
111
204
|
endpointpath: endpoint.path,
|
|
112
205
|
fhirversion: endpoint.fhirVersion,
|
|
113
|
-
ms: Date.now() - startTime
|
|
206
|
+
ms: Date.now() - startTime,
|
|
207
|
+
libraryLink: publishLibrarySource
|
|
208
|
+
? '<a href="' + escape(endpoint.path) + '/library" style="color: gold">Library</a> | '
|
|
209
|
+
: ''
|
|
114
210
|
};
|
|
115
211
|
|
|
116
212
|
return htmlServer.renderPage('tx', title, content, options);
|
|
@@ -244,7 +340,14 @@ class TxHtmlRenderer {
|
|
|
244
340
|
html += await this.buildSearchForm(req);
|
|
245
341
|
|
|
246
342
|
// ===== Packages and Factories Section =====
|
|
247
|
-
|
|
343
|
+
// What follows is the loaded content; the library YAML is where it came from, so when
|
|
344
|
+
// the operator publishes it, link it from the heading over the list it produced.
|
|
345
|
+
html += '<hr/><h3>Source Content';
|
|
346
|
+
if (publishLibrarySource) {
|
|
347
|
+
html += ` <a href="${escape(this.path)}/library" style="font-size: 60%; font-weight: normal;"` +
|
|
348
|
+
' title="The library source this server loaded this content from">source</a>';
|
|
349
|
+
}
|
|
350
|
+
html += '</h3>';
|
|
248
351
|
|
|
249
352
|
// List Packages
|
|
250
353
|
html += '<h6>FHIR Packages</h6>';
|
|
@@ -1371,5 +1474,6 @@ class TxHtmlRenderer {
|
|
|
1371
1474
|
}
|
|
1372
1475
|
|
|
1373
1476
|
module.exports = {
|
|
1374
|
-
TxHtmlRenderer, loadTemplate, acceptsHtml
|
|
1477
|
+
TxHtmlRenderer, loadTemplate, acceptsHtml,
|
|
1478
|
+
setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage
|
|
1375
1479
|
};
|
package/tx/tx.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
const express = require('express');
|
|
9
9
|
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
10
11
|
const Logger = require('../library/logger');
|
|
11
12
|
const { Library } = require('./library');
|
|
12
13
|
const { OperationContext, ResourceCache, ExpansionCache, debugLog} = require('./operation-context');
|
|
@@ -149,6 +150,10 @@ class TXModule {
|
|
|
149
150
|
// Load HTML template
|
|
150
151
|
txHtml.loadTemplate();
|
|
151
152
|
|
|
153
|
+
// Off unless the operator opts in. This gates the nav item as well as the route, so a
|
|
154
|
+
// server that does not publish its library source shows no link to it.
|
|
155
|
+
txHtml.setPublishLibrarySource(config.publishLibrarySource === true);
|
|
156
|
+
|
|
152
157
|
// Validate config
|
|
153
158
|
if (!config.librarySource) {
|
|
154
159
|
throw new Error('TX module requires librarySource configuration');
|
|
@@ -956,6 +961,45 @@ class TXModule {
|
|
|
956
961
|
}
|
|
957
962
|
});
|
|
958
963
|
|
|
964
|
+
// The library source YAML - the file this server loaded its content from.
|
|
965
|
+
//
|
|
966
|
+
// Only registered when the operator turns modules.tx.publishLibrarySource on: the file
|
|
967
|
+
// names every database, cache and package the server runs, which some deployments are
|
|
968
|
+
// happy to publish and others are not, so it is opt-in rather than opt-out.
|
|
969
|
+
//
|
|
970
|
+
// One URL, negotiated: a browser (Accept: text/html) gets the highlighted page, anything
|
|
971
|
+
// else gets the file itself. ?_format=html and ?_format=yaml force either way, through the
|
|
972
|
+
// same acceptsHtml() the rest of the module uses.
|
|
973
|
+
if (this.config.publishLibrarySource === true) {
|
|
974
|
+
router.get('/library', async (req, res) => {
|
|
975
|
+
const start = Date.now();
|
|
976
|
+
try {
|
|
977
|
+
const filename = this.config.librarySource;
|
|
978
|
+
let source;
|
|
979
|
+
try {
|
|
980
|
+
source = await fs.promises.readFile(filename, 'utf8');
|
|
981
|
+
} catch (error) {
|
|
982
|
+
this.log.error(`Error reading library source ${filename}: ${error.message}`);
|
|
983
|
+
res.status(500).json(this.operationOutcome('error', 'exception',
|
|
984
|
+
'The library source is published by this server, but could not be read'));
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (txHtml.acceptsHtml(req)) {
|
|
988
|
+
let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid, this.languages, this.i18n, req.txEndpoint.path);
|
|
989
|
+
const content = txHtml.buildLibrarySourcePage(source, path.basename(filename), req.txEndpoint.path);
|
|
990
|
+
const html = await txhtml.renderPage('Library Source', content, req.txEndpoint, req.txStartTime);
|
|
991
|
+
res.setHeader('Content-Type', 'text/html');
|
|
992
|
+
res.send(html);
|
|
993
|
+
} else {
|
|
994
|
+
res.setHeader('Content-Type', 'application/yaml; charset=utf-8');
|
|
995
|
+
res.send(source);
|
|
996
|
+
}
|
|
997
|
+
} finally {
|
|
998
|
+
this.countRequest(endpointPath, 'library', Date.now() - start);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
959
1003
|
// Metadata / CapabilityStatement
|
|
960
1004
|
router.get('/metadata', async (req, res) => {
|
|
961
1005
|
const start = Date.now();
|
package/tx/workers/validate.js
CHANGED
|
@@ -558,10 +558,10 @@ class ValueSetChecker {
|
|
|
558
558
|
} else {
|
|
559
559
|
this.worker.opContext.addNote(this.valueSet, 'found OK', this.indentCount);
|
|
560
560
|
result = true;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
messages.push(
|
|
564
|
-
|
|
561
|
+
const normalised = await cs.code(ctxt.context);
|
|
562
|
+
if (normalised && normalised !== code) {
|
|
563
|
+
messages.push(this.addNormalisationIssue(op, path, code, normalised, cs));
|
|
564
|
+
normalForm.value = normalised;
|
|
565
565
|
}
|
|
566
566
|
let msg = await cs.incompleteValidationMessage(ctxt.context, this.params.HTTPLanguages);
|
|
567
567
|
if (msg) {
|
|
@@ -1611,6 +1611,34 @@ class ValueSetChecker {
|
|
|
1611
1611
|
return result;
|
|
1612
1612
|
}
|
|
1613
1613
|
|
|
1614
|
+
/**
|
|
1615
|
+
* The code as submitted differs from the code as the code system renders it. Report why.
|
|
1616
|
+
*
|
|
1617
|
+
* Every such difference used to be reported as CODE_CASE_DIFFERENCE, which is where that
|
|
1618
|
+
* assumption came from - cs-lang, where the canonical form really does differ only in case.
|
|
1619
|
+
* It is not true in general. cs-snomed re-renders any postcoordinated expression through
|
|
1620
|
+
* renderExpression(Minimal), so every legal alternative serialisation (the optional comma
|
|
1621
|
+
* before an attribute group, whitespace, |terms|, attribute order) came back to the caller
|
|
1622
|
+
* as a complaint about their casing; ICD-11 normalises a URI to the code it denotes and did
|
|
1623
|
+
* the same. Neither has anything to do with case, and unlike case there is nothing the
|
|
1624
|
+
* caller did wrong - the submitted form is valid, it simply is not the normal form.
|
|
1625
|
+
*
|
|
1626
|
+
* So: case-only differences stay CODE_CASE_DIFFERENCE, everything else is
|
|
1627
|
+
* CODE_NOT_IN_NORMAL_FORM. Both are information severity (they were warning in one of the
|
|
1628
|
+
* two call sites and information in the other; every tx-ecosystem fixture expects
|
|
1629
|
+
* information).
|
|
1630
|
+
*
|
|
1631
|
+
* @returns {string} the message, for callers that also collect it into `messages`
|
|
1632
|
+
*/
|
|
1633
|
+
addNormalisationIssue(op, path, code, normalised, cs) {
|
|
1634
|
+
const caseOnly = normalised.toLowerCase() === code.toLowerCase();
|
|
1635
|
+
const id = caseOnly ? 'CODE_CASE_DIFFERENCE' : 'CODE_NOT_IN_NORMAL_FORM';
|
|
1636
|
+
const csDesc = cs.version() ? cs.system() + '|' + cs.version() : cs.system();
|
|
1637
|
+
const msg = this.worker.i18n.translate(id, this.params.HTTPLanguages, [code, normalised, csDesc]);
|
|
1638
|
+
op.addIssue(new Issue('information', 'business-rule', addToPath(path, 'code'), id, msg, 'code-rule'));
|
|
1639
|
+
return msg;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1614
1642
|
async checkConceptSet(path, role, cs, cset, code, displays, vs, message, inactive, normalForm, vstatus, op, vcc, messages) {
|
|
1615
1643
|
this.worker.opContext.addNote(vs, 'check code ' + role + ' ' + this.worker.renderer.displayValueSetInclude(cset) + ' at ' + path, this.indentCount);
|
|
1616
1644
|
let result = false;
|
|
@@ -1633,15 +1661,10 @@ class ValueSetChecker {
|
|
|
1633
1661
|
}
|
|
1634
1662
|
} else {
|
|
1635
1663
|
this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs), this.indentCount);
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
} else {
|
|
1641
|
-
msg = this.worker.i18n.translate('CODE_CASE_DIFFERENCE', this.params.HTTPLanguages, [code, await cs.code(loc.context), cs.system()]);
|
|
1642
|
-
}
|
|
1643
|
-
op.addIssue(new Issue('information', 'business-rule', addToPath(path, 'code'), 'CODE_CASE_DIFFERENCE', msg, 'code-rule'));
|
|
1644
|
-
normalForm.value = await cs.code(loc.context);
|
|
1664
|
+
const normalised = await cs.code(loc.context);
|
|
1665
|
+
if (normalised && normalised != code) {
|
|
1666
|
+
this.addNormalisationIssue(op, path, code, normalised, cs);
|
|
1667
|
+
normalForm.value = normalised;
|
|
1645
1668
|
}
|
|
1646
1669
|
let msg = await cs.incompleteValidationMessage(loc.context, this.params.HTTPLanguages);
|
|
1647
1670
|
if (msg) {
|