fhirsmith 0.14.0 → 0.14.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +3 -2
  3. package/config-template.json +20 -0
  4. package/library/fhir-openapi-schema.js +441 -0
  5. package/library/html-server.js +9 -3
  6. package/library/languages.js +15 -0
  7. package/library/npm-audit.js +265 -0
  8. package/library/openapi-doc.js +557 -0
  9. package/library/same-origin.js +62 -0
  10. package/library/utilities.js +21 -0
  11. package/nginx.md +17 -9
  12. package/package.json +10 -4
  13. package/packages/openapi.js +13 -0
  14. package/packages/openapi.yaml +603 -0
  15. package/packages/packages-template.html +4 -2
  16. package/packages/packages.js +286 -81
  17. package/packages/readme.md +120 -0
  18. package/publisher/publisher-template.html +1 -0
  19. package/publisher/publisher.js +95 -42
  20. package/registry/api.js +117 -41
  21. package/registry/crawler.js +24 -0
  22. package/registry/fhirsmith-releases.js +170 -0
  23. package/registry/model.js +10 -0
  24. package/registry/openapi.js +13 -0
  25. package/registry/openapi.yaml +311 -0
  26. package/registry/readme.md +107 -208
  27. package/registry/registry-template.html +4 -1
  28. package/registry/registry.js +183 -20
  29. package/server.js +25 -6
  30. package/testing/openapi-schemas.config.js +58 -0
  31. package/testing/openapi-schemas.json +1920 -0
  32. package/testing/openapi.js +15 -0
  33. package/testing/openapi.yaml +273 -0
  34. package/testing/readme.md +75 -1
  35. package/testing/render.js +270 -29
  36. package/testing/search.js +1 -1
  37. package/testing/store.js +152 -8
  38. package/testing/testing-template.html +2 -0
  39. package/testing/testing.js +307 -9
  40. package/translations/Messages.properties +1 -0
  41. package/tx/README.md +58 -0
  42. package/tx/cm/cm-database.js +10 -1
  43. package/tx/cm/cm-package.js +8 -1
  44. package/tx/cs/cs-rxnorm.js +23 -6
  45. package/tx/cs/cs-snomed.js +3 -72
  46. package/tx/html/tx-template.html +2 -0
  47. package/tx/library/canonical-resource.js +50 -1
  48. package/tx/library/codesystem.js +1 -0
  49. package/tx/library/conceptmap.js +1 -0
  50. package/tx/library/valueset.js +16 -0
  51. package/tx/library.js +17 -2
  52. package/tx/openapi-operations.js +363 -0
  53. package/tx/openapi-schemas.config.js +118 -0
  54. package/tx/openapi-schemas.json +7903 -0
  55. package/tx/openapi.js +289 -0
  56. package/tx/openapi.yaml +151 -0
  57. package/tx/operation-context.js +15 -0
  58. package/tx/params.js +3 -12
  59. package/tx/provider.js +10 -1
  60. package/tx/sct/editions.js +70 -0
  61. package/tx/tx-html.js +6 -0
  62. package/tx/tx.js +55 -0
  63. package/tx/vs/vs-database.js +14 -5
  64. package/tx/vs/vs-package.js +10 -0
  65. package/tx/workers/expand.js +37 -18
  66. package/tx/workers/metadata.js +0 -13
  67. package/tx/workers/translate.js +25 -1
  68. package/tx/workers/validate.js +74 -24
  69. package/tx/workers/worker.js +32 -43
  70. package/utilities/extract-schema-json.js +167 -0
  71. package/utilities/generate-openapi-schemas.js +49 -0
  72. package/utilities/submit-test-report.js +1 -1
  73. package/xig/xig.js +2 -4
package/CHANGELOG.md CHANGED
@@ -5,6 +5,70 @@ All notable changes to Health Intersections FHIRsmith will be documented in this
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
+ ## [0.14.2] - 2026-10-06
9
+
10
+ ### Security
11
+
12
+ - Dependency updates for published advisories, including axios (several high severity), brace-expansion (denial of service), and http-cache-semantics and @tootallnate/once (removed with the old sqlite3 build chain)
13
+
14
+ ### Added
15
+
16
+ - OpenAPI descriptions for the package server (/packages), the terminology registry (/tx-reg), the TestReport module (/testing) and the R5 terminology endpoints: each serves `openapi.json`, `openapi.yaml` and an HTML reference at `openapi` (JSON for non-browser clients), linked from the module's pages and advertised in an RFC 8631 `Link` header on every response. The FHIR resource schemas are generated from the FHIR definitions, constrained to what each module accepts
17
+ - npm audit self-check: once a day the server checks its installed packages (and FHIRsmith itself) against the npm advisory database - report only, nothing is changed. Findings show as a banner on the home page, in full on the dashboard, and in the log. `npmAudit.enabled` = false turns it off (e.g. for servers with no internet access)
18
+ - Registry: new Software page (/tx-reg/software) listing each registered server's software and version, and for FHIRsmith servers, the release date, its age, and how many releases behind it is. The crawler now records `CapabilityStatement.software.version`
19
+
20
+ ### Changed
21
+
22
+ - Terminology server: contained resources are supported only for ValueSets that contain ValueSets (which `compose.include.valueSet` can import by `#id`). Any other contained resource is rejected (CONTAINED_RESOURCE_NOT_SUPPORTED) wherever the resource comes from; when loading a package, the offending resource is skipped rather than stopping the load
23
+ - $validate-code: the `inactive` and `status` output parameters describe the code being returned. In a CodeableConcept where a different coding is inactive, that coding still gets its warning, but the parameters are not set for the returned code
24
+ - Registry discovery API follows the tx ecosystem IG: rows carry `fhirVersion` and the IG's security flags (`open`, `token`, ...) alongside the existing security string; candidate lists are only given when `url` is supplied; R-codes (R4, R4B, R5, ...) map to their release versions; and the resolve fallback that returned servers authoritative for a code system but not hosting it has been removed
25
+ - The TerminologyCapabilities statement no longer lists expansion parameters the server doesn't act on (`limitedExpansion`, `_incomplete`, `incomplete-ok` and others); `limitedExpansion`/`incomplete-ok` are no longer read, and no longer part of the expansion cache key (they never had any effect)
26
+ - TestReport module: a TestReport with contained resources is refused
27
+ - Packages and registry page footers say when the crawler last updated the data ("last updated 35 minutes ago"), or "not yet updated" before the first crawl - they used to show a raw `[%crawler-date%]` placeholder
28
+ - Packages crawler log: the start time is shown relative ("35 minutes ago") and the duration in seconds; the end time is dropped
29
+ - Dependencies: sqlite3 6 (connect-sqlite3 now uses the same sqlite3, which drops the old node-gyp/make-fetch-happen chain), and updates for axios, brace-expansion, moment, ip-address, csv-parse and uuid. The PR build now fails only on high/critical advisories in what ships (`npm audit --omit=dev`)
30
+
31
+ ### Fixed
32
+
33
+ - $expand: RxNorm expansions that asked for a concept's children failed with `SQLITE_MISUSE`; an over-limit expansion of the whole of RxNorm is now refused before it is built
34
+ - $expand: a value set with neither a compose nor an expansion gets a clear error (VALUESET_NO_COMPOSE)
35
+ - $validate-code: in a CodeableConcept, the inactive warning names the coding that is actually inactive (it used to name the first coding), and a later active coding no longer hides the warning from an earlier inactive one
36
+ - Packages: `/status`, `/stats` and `/search` hung instead of answering (the `/:id` route swallowed them)
37
+ - Packages: dependency searches accept `id#version` and `id|version` (as sent by the Java PackageClient) as well as `id@version`; npm search (`/-/v1/search`) honours `text`, `size` and `from`, and accepts the other npm and PackageClient parameters
38
+ - XIG: the version shown on the pages
39
+
40
+ ### Tx Conformance Statement
41
+
42
+ FHIRsmith passed all 3585 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v6.10.4)
43
+
44
+ ## [0.14.1] - 2026-09-29
45
+
46
+ ### Added
47
+
48
+ - TestReport module: user administration - the administrator (`adminPassword`) manages users at /testing/admin/users, with rights to edit named links and/or delete reports
49
+ - TestReport module: named links - test script and participant URLs shown by name, maintained at /testing/admin/links
50
+ - TestReport module: the report list shows the test engine, client and server, the number of tests and the run length; a report page shows setup, each test with its operations and assertions, and teardown
51
+ - Test reports from the FHIRsmith build name the test modes they ran with
52
+
53
+ ### Changed
54
+
55
+ - Security: the publisher and testing web pages require a CSRF token on every form post (lusca), a SameSite=Lax session cookie, and a same-origin `Origin` on posts; the session id is regenerated on login
56
+ - Security: the publisher rate limits task actions (create, approve, delete, retry) and task output reads, as well as logins
57
+ - `server.trustProxy` now defaults to trusting only loopback, link-local and private-network proxies (was `true`, which let anyone reaching the port directly set their own client address and get round the rate limits). See nginx.md - a proxy elsewhere needs to be configured, and the proxy must pass on `Host` or `X-Forwarded-Host`, or form posts are refused
58
+ - $expand: the `displayLanguage` expansion parameter is written without spaces (`en,*;q=0`, not `en, *; q=0`)
59
+ - $translate: `result` is only true if at least one match is a translation - a `not-related-to` (R4: `disjoint`/`unmatched`) or `noMap` match is still returned, but `result` is false
60
+ - SNOMED CT edition names are held in one table (tx/sct/editions.js), used by the provider and the unknown code messages
61
+
62
+ ### Fixed
63
+
64
+ - $validate-code: when the code is not in the value set, it is looked up in the code system version the value set selects (`compose.include.version`), not the server's default version - so the version and display reported come from the right edition
65
+ - $validate-code: the unknown code message named the SNOMED CT test edition as "unknown"; it is now "Test Edition"
66
+ - Registry: the SNOMED CT test edition (`http://snomed.info/xsct/31000003106`) is labelled, rather than shown as `??`
67
+
68
+ ### Tx Conformance Statement
69
+
70
+ FHIRsmith passed all 3585 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v6.10.4)
71
+
8
72
  ## [0.14.0] - 2026-09-27
9
73
 
10
74
  ### Added
package/README.md CHANGED
@@ -10,10 +10,11 @@ This server provides a set of server-side services that are useful for the FHIR
10
10
 
11
11
  ## Services useful the community as a whole
12
12
 
13
- * [TX Registry](registry/readme.md) - **Terminology System Registry** as [described by the terminology ecosystem specification](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig) (as running at http://tx.fhir.org/tx-reg)
14
- * [Package server](packages/readme.md) - **NPM-style FHIR package registry** with search, versioning, and downloads, consistent with the FHIR NPM Specification (as running at http://packages2.fhir.org/packages)
13
+ * [TX Registry](registry/readme.md) - **Terminology System Registry** as [described by the terminology ecosystem specification](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig) (as running at http://tx.fhir.org/tx-reg). Its API is described by an OpenAPI 3.1 spec at `/tx-reg/openapi.json` (and `.yaml`), with a browsable reference at `/tx-reg/openapi`
14
+ * [Package server](packages/readme.md) - **NPM-style FHIR package registry** with search, versioning, and downloads, consistent with the FHIR NPM Specification (as running at http://packages2.fhir.org/packages). Its API is described by an OpenAPI 3.1 spec at `/packages/openapi.json` (and `.yaml`), with a browsable reference at `/packages/openapi`
15
15
  * [XIG server](xig/readme.md) - **Comprehensive FHIR IG analytics** with resource breakdowns by version, authority, and realm (as running at http://packages2.fhir.org/packages)
16
16
  * [Publisher](publisher/readme.md) - FHIR publishing services (as running at [healthintersections.com.au](http://www.healthintersections.com.au/publisher))
17
+ * [Testing](testing/readme.md) - **TestReport repository**: receives FHIR TestReports from TxTester and other test tools, with a FHIR API and web pages (as running at https://testing.fhir.org/testing). Its API is described by an OpenAPI 3.1 spec at `/testing/openapi.json` (and `.yaml`), with a browsable reference at `/testing/openapi`
17
18
  * [VCL](vcl/readme.md) - **Parse VCL expressions** into FHIR ValueSet resources (as running at http://fhir.org/vcl)
18
19
  * (Coming) Token services
19
20
 
@@ -18,6 +18,15 @@
18
18
  // how often the counters are written out
19
19
  "intervalMinutes": 10
20
20
  },
21
+ // Once a day the server checks its installed npm packages against the npm advisory
22
+ // database (what `npm audit` does - report only, nothing is changed). Problems show on
23
+ // the home page and the dashboard, and in the log. Needs outbound access to
24
+ // registry.npmjs.org. The whole block is optional.
25
+ "npmAudit": {
26
+ // set false to turn the check off (e.g. a server with no internet access)
27
+ "enabled": true,
28
+ "intervalHours": 24
29
+ },
21
30
  "modules": {
22
31
  "shl": {
23
32
  "enabled": false,
@@ -88,6 +97,10 @@
88
97
  "cookieSecure": true,
89
98
  // failed-login attempts allowed per IP per 15 minutes (default 20)
90
99
  "loginAttemptsPerWindow": 20,
100
+ // per IP address per minute: task actions (create, approve, delete, retry), and reads
101
+ // of task output, history and QA pages and files
102
+ "taskActionsPerMinute": 30,
103
+ "taskReadsPerMinute": 300,
91
104
  "workspaceRoot": "/absolute/path/to/workspaces",
92
105
  "igPublisherTimeoutMinutes": 60,
93
106
  // where to put files the IG Publisher leaves in the web folder that are too big for GitHub
@@ -261,6 +274,13 @@
261
274
  "tokenHeader": "Authorization",
262
275
  // DELETE of a report needs this token, in the same header. No adminToken, no deletes
263
276
  // "adminToken": "<!-- secret -->",
277
+ // the web administration pages: log in as 'admin' with this password to manage users
278
+ // (who can edit the named links and/or delete reports). No adminPassword, no administrator
279
+ // "adminPassword": "<!-- secret -->",
280
+ // signs the login cookie; without it logins don't survive a restart
281
+ // "sessionSecret": "<!-- random string -->",
282
+ // the login cookie is HTTPS-only unless this is false (e.g. for local testing over http)
283
+ // "cookieSecure": true,
264
284
  // bytes
265
285
  "maxSize": 512000,
266
286
  // reports per IP address per window; max 0 turns the limit off
@@ -0,0 +1,441 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * Generates OpenAPI 3.1 (JSON Schema 2020-12) component schemas for FHIR resources from
9
+ * their StructureDefinitions.
10
+ *
11
+ * The published FHIR JSON schema doesn't work well in an OpenAPI description: every
12
+ * resource reaches every other one (through contained resources and Bundle entries) and
13
+ * every datatype (through extensions), so referencing one resource drags in the whole
14
+ * specification. This generator produces only what the named resources actually use, and
15
+ * cuts the recursion at two fixed boundaries:
16
+ *
17
+ * - an element whose type is a resource (Bundle.entry.resource, and so on) is "any
18
+ * resource": an object with a resourceType, otherwise not described. A module can
19
+ * narrow it with an overlay (a search Bundle of TestReports, say)
20
+ * - an extension is described as an object with a url and a value[x], but the value isn't
21
+ * described further
22
+ *
23
+ * Elements can be prohibited outright (the testing module doesn't accept contained
24
+ * resources), and an overlay can tighten the generated schemas to what an endpoint really
25
+ * requires.
26
+ *
27
+ * A module can move the boundaries, when it knows what it handles:
28
+ * - `resources` names the resources an element of type Resource can hold, by path
29
+ * (Bundle.entry.resource: CodeSystem or ValueSet, say); they're generated too. An
30
+ * element whose type is a particular resource (Bundle.issues: OperationOutcome) is always
31
+ * that resource
32
+ * - `choiceTypes` restricts the types of a choice element, by path
33
+ * (Parameters.parameter.value[x]: the primitives, say)
34
+ * - `generateExtension` describes Extension fully, from its StructureDefinition, instead
35
+ * of as a boundary; usually with choiceTypes for Extension.value[x]
36
+ *
37
+ * What's generated:
38
+ * - one schema per resource and complex datatype, named for the type, and one per backbone
39
+ * element, named for its path (TestReport_Setup_Action); contentReference becomes a $ref
40
+ * - every object is closed (additionalProperties: false), as FHIR JSON is
41
+ * - a primitive element `x` gets its value (with the type's regex as a pattern, and the
42
+ * codes of a required binding as an enum where they can be worked out from the package)
43
+ * and its `_x` sibling for the element's id and extensions. In a repeating primitive,
44
+ * either array may have nulls where the other has an entry
45
+ * - choice elements (value[x]) are expanded to their concrete names (valueString ...)
46
+ * - required: elements with min >= 1, and resourceType
47
+ *
48
+ * @module library/fhir-openapi-schema
49
+ */
50
+
51
+ const fs = require('fs');
52
+ const path = require('path');
53
+
54
+ const REGEX_EXT = 'http://hl7.org/fhir/StructureDefinition/regex';
55
+ const SYSTEM_STRING = 'http://hl7.org/fhirpath/System.String';
56
+
57
+ /** The boundary schemas; included where something reaches them. */
58
+ const BOUNDARY_SCHEMAS = {
59
+ Extension: {
60
+ type: 'object',
61
+ description: 'An extension. The value (value[x]) can be any FHIR datatype, and is not described further here.',
62
+ properties: {
63
+ id: { type: 'string' },
64
+ url: { type: 'string', description: 'The extension\'s definition' },
65
+ extension: { type: 'array', items: { $ref: '#/components/schemas/Extension' } }
66
+ },
67
+ patternProperties: {
68
+ '^_?value[A-Z][A-Za-z0-9]*$': { description: 'The value (value[x]), and its _value[x] sibling for a primitive' }
69
+ },
70
+ required: ['url'],
71
+ additionalProperties: false
72
+ },
73
+ AnyResource: {
74
+ type: 'object',
75
+ description: 'A FHIR resource of any type. Not described further here.',
76
+ properties: {
77
+ resourceType: { type: 'string' }
78
+ },
79
+ required: ['resourceType'],
80
+ additionalProperties: true
81
+ }
82
+ };
83
+
84
+ function ref(name) {
85
+ return { $ref: `#/components/schemas/${name}` };
86
+ }
87
+
88
+ function cap(s) {
89
+ return s.charAt(0).toUpperCase() + s.slice(1);
90
+ }
91
+
92
+ function schemaNameForPath(p) {
93
+ return p.split('.').map(cap).join('_');
94
+ }
95
+
96
+ function extValue(type, url) {
97
+ const e = (type.extension || []).find(x => x.url === url);
98
+ return e ? (e.valueUrl || e.valueString || e.valueUri) : undefined;
99
+ }
100
+
101
+ /**
102
+ * The StructureDefinitions, CodeSystems and ValueSets in an unpacked FHIR package.
103
+ */
104
+ class FhirPackage {
105
+ constructor(dir) {
106
+ this.dir = dir;
107
+ this.sds = new Map();
108
+ this.byUrl = new Map();
109
+ for (const f of fs.readdirSync(dir)) {
110
+ if (!f.endsWith('.json') || !/^(StructureDefinition|CodeSystem|ValueSet)-/.test(f)) {
111
+ continue;
112
+ }
113
+ const r = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
114
+ if (r.url) {
115
+ this.byUrl.set(r.url, r);
116
+ }
117
+ if (r.resourceType === 'StructureDefinition' && r.derivation === 'specialization') {
118
+ this.sds.set(r.type, r);
119
+ }
120
+ }
121
+ const pj = path.join(dir, 'package.json');
122
+ this.id = fs.existsSync(pj) ? (() => {
123
+ const p = JSON.parse(fs.readFileSync(pj, 'utf8'));
124
+ return `${p.name}#${p.version}`;
125
+ })() : dir;
126
+ }
127
+
128
+ sd(type) {
129
+ return this.sds.get(type);
130
+ }
131
+
132
+ /** The codes of a value set, if they can be worked out simply; otherwise null. */
133
+ codes(vsUrl) {
134
+ const vs = this.byUrl.get(vsUrl.split('|')[0]);
135
+ if (!vs || !vs.compose || vs.compose.exclude || !Array.isArray(vs.compose.include)) {
136
+ return null;
137
+ }
138
+ const codes = [];
139
+ for (const inc of vs.compose.include) {
140
+ if (inc.filter || inc.valueSet || !inc.system) {
141
+ return null;
142
+ }
143
+ if (inc.concept) {
144
+ codes.push(...inc.concept.map(c => c.code));
145
+ } else {
146
+ const cs = this.byUrl.get(inc.system);
147
+ if (!cs || cs.content !== 'complete' || !cs.concept) {
148
+ return null;
149
+ }
150
+ const walk = (list) => list.forEach(c => {
151
+ codes.push(c.code);
152
+ if (c.concept) {
153
+ walk(c.concept);
154
+ }
155
+ });
156
+ walk(cs.concept);
157
+ }
158
+ }
159
+ return codes.length > 0 ? codes : null;
160
+ }
161
+ }
162
+
163
+ class FhirSchemaGenerator {
164
+ /**
165
+ * @param {FhirPackage} pkg
166
+ * @param {Object} [options]
167
+ * @param {string[]} [options.prohibit] - elements that are left out: a name (e.g.
168
+ * 'contained') leaves the element out of every resource and type, a path (e.g.
169
+ * 'CodeSystem.contained') out of that one place. The schemas are closed, so they're
170
+ * then not allowed
171
+ * @param {Object<string, string[]>} [options.resources] - path -> the resource types an
172
+ * element of type Resource may hold (otherwise it's AnyResource)
173
+ * @param {Object<string, string[]>} [options.choiceTypes] - path of a choice element
174
+ * (e.g. 'Parameters.parameter.value[x]') -> the types allowed (otherwise all of them)
175
+ * @param {boolean} [options.generateExtension] - describe Extension from its
176
+ * StructureDefinition rather than as a boundary
177
+ */
178
+ constructor(pkg, options = {}) {
179
+ this.pkg = pkg;
180
+ this.prohibit = new Set(options.prohibit || []);
181
+ this.resources = options.resources || {};
182
+ this.choiceTypes = options.choiceTypes || {};
183
+ this.boundary = { ...BOUNDARY_SCHEMAS };
184
+ if (options.generateExtension) {
185
+ delete this.boundary.Extension;
186
+ }
187
+ this.schemas = {};
188
+ this.queue = [];
189
+ this.queued = new Set();
190
+ }
191
+
192
+ /**
193
+ * @param {string[]} roots - the resources (and types) to describe
194
+ * @returns {Object} name -> schema
195
+ */
196
+ generate(roots) {
197
+ Object.assign(this.schemas, structuredClone(this.boundary));
198
+ roots.forEach(r => this.enqueue(r));
199
+ while (this.queue.length > 0) {
200
+ this.generateType(this.queue.shift());
201
+ }
202
+ // a boundary nothing ended up at isn't needed
203
+ const used = JSON.stringify(this.schemas);
204
+ for (const name of Object.keys(this.boundary)) {
205
+ if (!used.includes(`"#/components/schemas/${name}"`)) {
206
+ delete this.schemas[name];
207
+ }
208
+ }
209
+ // a stable order, so the generated file diffs cleanly
210
+ const sorted = {};
211
+ for (const k of Object.keys(this.schemas).sort()) {
212
+ sorted[k] = this.schemas[k];
213
+ }
214
+ return sorted;
215
+ }
216
+
217
+ enqueue(type) {
218
+ if (!this.queued.has(type) && !this.boundary[type]) {
219
+ this.queued.add(type);
220
+ this.queue.push(type);
221
+ }
222
+ }
223
+
224
+ generateType(type) {
225
+ const sd = this.pkg.sd(type);
226
+ if (!sd) {
227
+ throw new Error(`No StructureDefinition for ${type} in ${this.pkg.id}`);
228
+ }
229
+ if (sd.kind === 'primitive-type') {
230
+ throw new Error(`${type} is a primitive type, and has no schema of its own`);
231
+ }
232
+ this.elements = sd.snapshot.element;
233
+ this.buildObject(type, type, sd.kind === 'resource', sd.description);
234
+ }
235
+
236
+ children(p) {
237
+ const prefix = p + '.';
238
+ return this.elements.filter(e => e.path.startsWith(prefix) && !e.path.substring(prefix.length).includes('.'));
239
+ }
240
+
241
+ buildObject(schemaName, p, isResource, description) {
242
+ const schema = { type: 'object' };
243
+ if (description) {
244
+ schema.description = description;
245
+ }
246
+ const properties = {};
247
+ const required = [];
248
+ if (isResource) {
249
+ properties.resourceType = { const: p };
250
+ required.push('resourceType');
251
+ }
252
+ for (const el of this.children(p)) {
253
+ const name = el.path.substring(p.length + 1);
254
+ if (el.max === '0' || this.prohibit.has(name) || this.prohibit.has(el.path)) {
255
+ continue;
256
+ }
257
+ if (name.endsWith('[x]')) {
258
+ const base = name.slice(0, -3);
259
+ const allowed = this.choiceTypes[el.path];
260
+ if (allowed) {
261
+ const unknown = allowed.filter(t => !el.type.some(et => et.code === t));
262
+ if (unknown.length > 0) {
263
+ throw new Error(`${el.path} can't be ${unknown.join(', ')}`);
264
+ }
265
+ }
266
+ for (const t of el.type.filter(t => !allowed || allowed.includes(t.code))) {
267
+ this.addProperty(properties, base + cap(t.code), el, t);
268
+ }
269
+ } else {
270
+ this.addProperty(properties, name, el, el.type ? el.type[0] : null);
271
+ if (el.min >= 1) {
272
+ required.push(name);
273
+ }
274
+ }
275
+ }
276
+ schema.properties = properties;
277
+ if (required.length > 0) {
278
+ schema.required = required;
279
+ }
280
+ schema.additionalProperties = false;
281
+ this.schemas[schemaName] = schema;
282
+ }
283
+
284
+ addProperty(properties, name, el, type) {
285
+ const many = el.max !== '1';
286
+ const description = el.short;
287
+ let value;
288
+ let primitive = false;
289
+
290
+ if (el.contentReference) {
291
+ value = ref(schemaNameForPath(el.contentReference.replace(/^.*#/, '')));
292
+ } else if (!type) {
293
+ throw new Error(`${el.path} has no type`);
294
+ } else if (type.code === SYSTEM_STRING) {
295
+ // element ids, and the like
296
+ value = { type: 'string' };
297
+ } else if (type.code === 'BackboneElement' || (type.code === 'Element' && this.children(el.path).length > 0)) {
298
+ const sn = schemaNameForPath(el.path);
299
+ this.buildObject(sn, el.path, false, el.definition);
300
+ value = ref(sn);
301
+ } else if (type.code === 'Extension') {
302
+ this.enqueue('Extension');
303
+ value = ref('Extension');
304
+ } else {
305
+ const tsd = this.pkg.sd(type.code);
306
+ if (!tsd) {
307
+ throw new Error(`${el.path}: no StructureDefinition for type ${type.code}`);
308
+ }
309
+ if (type.code === 'Resource' || type.code === 'DomainResource') {
310
+ const allowed = this.resources[el.path];
311
+ if (allowed) {
312
+ allowed.forEach(t => this.enqueue(t));
313
+ value = allowed.length === 1 ? ref(allowed[0]) : { anyOf: allowed.map(ref) };
314
+ } else {
315
+ value = ref('AnyResource');
316
+ }
317
+ } else if (tsd.kind === 'resource') {
318
+ this.enqueue(type.code);
319
+ value = ref(type.code);
320
+ } else if (tsd.kind === 'primitive-type') {
321
+ primitive = true;
322
+ value = this.primitive(tsd, el);
323
+ } else {
324
+ this.enqueue(type.code);
325
+ value = ref(type.code);
326
+ }
327
+ }
328
+
329
+ if (primitive) {
330
+ // the value, and the _x sibling for its id and extensions
331
+ if (many) {
332
+ properties[name] = { type: 'array', description, items: { anyOf: [value, { type: 'null' }] } };
333
+ properties['_' + name] = { type: 'array', items: { anyOf: [ref('Element'), { type: 'null' }] } };
334
+ } else {
335
+ properties[name] = { ...value, description };
336
+ properties['_' + name] = ref('Element');
337
+ }
338
+ this.enqueue('Element');
339
+ } else if (many) {
340
+ properties[name] = { type: 'array', description, items: value };
341
+ } else {
342
+ properties[name] = { ...value, description };
343
+ }
344
+ }
345
+
346
+ primitive(tsd, el) {
347
+ const valueEl = tsd.snapshot.element.find(e => e.path === `${tsd.type}.value`);
348
+ const vt = valueEl && valueEl.type ? valueEl.type[0] : {};
349
+ // x-fhir-type records the FHIR type, which the JSON type doesn't say (dateTime, code, uri
350
+ // are all strings); the reference page shows it instead of the regex
351
+ const schema = { 'x-fhir-type': tsd.type };
352
+ switch (tsd.type) {
353
+ case 'boolean': schema.type = 'boolean'; break;
354
+ case 'integer': schema.type = 'integer'; break;
355
+ case 'positiveInt': schema.type = 'integer'; schema.minimum = 1; break;
356
+ case 'unsignedInt': schema.type = 'integer'; schema.minimum = 0; break;
357
+ case 'decimal': schema.type = 'number'; break;
358
+ default: schema.type = 'string';
359
+ }
360
+ if (schema.type === 'string') {
361
+ const regex = extValue(vt, REGEX_EXT);
362
+ if (regex) {
363
+ schema.pattern = regex.startsWith('^') ? regex : `^(${regex})$`;
364
+ }
365
+ }
366
+ if (tsd.type === 'code' && el.binding && el.binding.strength === 'required' && el.binding.valueSet) {
367
+ const codes = this.pkg.codes(el.binding.valueSet);
368
+ if (codes) {
369
+ schema.enum = codes;
370
+ delete schema.pattern;
371
+ }
372
+ }
373
+ return schema;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Applies an overlay to generated schemas: objects merge, `required` lists are unioned,
379
+ * and anything else in the overlay replaces what was generated. A schema in the overlay
380
+ * that wasn't generated is added as is.
381
+ */
382
+ // keys that would reach an object's prototype rather than the object
383
+ function isUnsafeKey(k) {
384
+ return k === '__proto__' || k === 'constructor' || k === 'prototype';
385
+ }
386
+
387
+ function applyOverlay(schemas, overlay) {
388
+ const merge = (target, src) => {
389
+ for (const [k, v] of Object.entries(src)) {
390
+ if (isUnsafeKey(k)) {
391
+ throw new Error(`An overlay can't set '${k}'`);
392
+ }
393
+ // only the target's own properties are merged into, never anything it inherits
394
+ const own = Object.prototype.hasOwnProperty.call(target, k);
395
+ if (k === 'required' && Array.isArray(target.required)) {
396
+ target.required = [...new Set([...target.required, ...v])];
397
+ } else if (own && v && typeof v === 'object' && !Array.isArray(v) && target[k] && typeof target[k] === 'object' && !Array.isArray(target[k]) && !v.$replace) {
398
+ merge(target[k], v);
399
+ } else {
400
+ const value = v && v.$replace ? { ...v } : v;
401
+ if (value && value.$replace) {
402
+ delete value.$replace;
403
+ }
404
+ target[k] = value;
405
+ }
406
+ }
407
+ };
408
+ for (const [name, s] of Object.entries(overlay || {})) {
409
+ if (isUnsafeKey(name)) {
410
+ throw new Error(`An overlay can't set '${name}'`);
411
+ }
412
+ if (Object.prototype.hasOwnProperty.call(schemas, name)) {
413
+ merge(schemas[name], s);
414
+ } else {
415
+ schemas[name] = s;
416
+ }
417
+ }
418
+ return schemas;
419
+ }
420
+
421
+ /**
422
+ * Generates the schemas a module's config asks for.
423
+ *
424
+ * @param {Object} config - { package, roots, prohibit, resources, choiceTypes,
425
+ * generateExtension, overlay } (see FhirSchemaGenerator)
426
+ * @param {string} packageDir - the unpacked package
427
+ * @returns {{generatedFrom: string, schemas: Object}}
428
+ */
429
+ function generateSchemas(config, packageDir) {
430
+ const pkg = new FhirPackage(packageDir);
431
+ const gen = new FhirSchemaGenerator(pkg, {
432
+ prohibit: config.prohibit,
433
+ resources: config.resources,
434
+ choiceTypes: config.choiceTypes,
435
+ generateExtension: config.generateExtension
436
+ });
437
+ const schemas = applyOverlay(gen.generate(config.roots), structuredClone(config.overlay || {}));
438
+ return { generatedFrom: pkg.id, schemas };
439
+ }
440
+
441
+ module.exports = { FhirPackage, FhirSchemaGenerator, applyOverlay, generateSchemas, BOUNDARY_SCHEMAS };
@@ -8,6 +8,7 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const escape = require('escape-html');
11
+ const packageJson = require('../package.json');
11
12
 
12
13
  let sponsorMessage = '';
13
14
 
@@ -60,7 +61,6 @@ class HtmlServer {
60
61
 
61
62
  // Default options
62
63
  const renderOptions = {
63
- version: '4.0.1',
64
64
  downloadDate: 'Unknown',
65
65
  totalResources: 0,
66
66
  totalPackages: 0,
@@ -72,8 +72,11 @@ class HtmlServer {
72
72
  let html = template
73
73
  .replace(/\[%title%\]/g, escape(title))
74
74
  .replace(/\[%content%\]/g, content) // Content is assumed to be already-safe HTML
75
- .replace(/\[%ver%\]/g, escape(renderOptions.version))
75
+ // [%ver%] is the FHIRsmith version in every template (it follows the FHIRsmith link)
76
+ .replace(/\[%ver%\]/g, escape(packageJson.version))
76
77
  .replace(/\[%download-date%\]/g, escape(renderOptions.downloadDate))
78
+ // "last updated 35 minutes ago" / "not yet updated", for the crawler-driven modules
79
+ .replace(/\[%crawler-status%\]/g, escape(renderOptions.crawlerStatus || 'not yet updated'))
77
80
  .replace(/\[%total-resources%\]/g, escape(renderOptions.totalResources.toLocaleString()))
78
81
  .replace(/\[%total-packages%\]/g, escape(renderOptions.totalPackages.toLocaleString()))
79
82
  .replace(/\[%endpoint-path%\]/g, escape(renderOptions.endpointpath))
@@ -82,7 +85,10 @@ class HtmlServer {
82
85
  .replace(/\[%sponsorMessage%\]/g, sponsorMessage)
83
86
  .replace(/\[%about%\]/g, renderOptions.about || '')
84
87
  // raw HTML, like [%about%]: the caller decides whether the nav item exists at all
85
- .replace(/\[%library-link%\]/g, renderOptions.libraryLink || '');
88
+ .replace(/\[%library-link%\]/g, renderOptions.libraryLink || '')
89
+ // raw HTML too: the OpenAPI description, where a template's module has one in some places only
90
+ .replace(/\[%api-link%\]/g, renderOptions.apiLink || '')
91
+ .replace(/\[%api-head%\]/g, renderOptions.apiHead || '');
86
92
 
87
93
  // Handle any custom template variables
88
94
  if (options.templateVars) {
@@ -562,6 +562,21 @@ class Languages {
562
562
  }
563
563
  return parts.join(', ');
564
564
  }
565
+
566
+ /**
567
+ * The languages as they were asked for, in the form a displayLanguage parameter takes
568
+ * (the Accept-Language syntax: "en,it;q=0.8,*"). Unlike asString, which is for reading
569
+ * in messages, there are no spaces; unlike toString, implicit languages are left out.
570
+ */
571
+ asParameter() {
572
+ return this.languages.filter(lang => !lang.implicit).map(lang => {
573
+ if (lang.quality === undefined || lang.quality === 1.0) {
574
+ return lang.code;
575
+ } else {
576
+ return `${lang.code};q=${lang.quality}`;
577
+ }
578
+ }).join(',');
579
+ }
565
580
  }
566
581
 
567
582
  /**