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.
- package/CHANGELOG.md +64 -0
- package/README.md +3 -2
- package/config-template.json +20 -0
- package/library/fhir-openapi-schema.js +441 -0
- package/library/html-server.js +9 -3
- package/library/languages.js +15 -0
- package/library/npm-audit.js +265 -0
- package/library/openapi-doc.js +557 -0
- package/library/same-origin.js +62 -0
- package/library/utilities.js +21 -0
- package/nginx.md +17 -9
- package/package.json +10 -4
- package/packages/openapi.js +13 -0
- package/packages/openapi.yaml +603 -0
- package/packages/packages-template.html +4 -2
- package/packages/packages.js +286 -81
- package/packages/readme.md +120 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +95 -42
- package/registry/api.js +117 -41
- package/registry/crawler.js +24 -0
- package/registry/fhirsmith-releases.js +170 -0
- package/registry/model.js +10 -0
- package/registry/openapi.js +13 -0
- package/registry/openapi.yaml +311 -0
- package/registry/readme.md +107 -208
- package/registry/registry-template.html +4 -1
- package/registry/registry.js +183 -20
- package/server.js +25 -6
- package/testing/openapi-schemas.config.js +58 -0
- package/testing/openapi-schemas.json +1920 -0
- package/testing/openapi.js +15 -0
- package/testing/openapi.yaml +273 -0
- package/testing/readme.md +75 -1
- package/testing/render.js +270 -29
- package/testing/search.js +1 -1
- package/testing/store.js +152 -8
- package/testing/testing-template.html +2 -0
- package/testing/testing.js +307 -9
- package/translations/Messages.properties +1 -0
- package/tx/README.md +58 -0
- package/tx/cm/cm-database.js +10 -1
- package/tx/cm/cm-package.js +8 -1
- package/tx/cs/cs-rxnorm.js +23 -6
- package/tx/cs/cs-snomed.js +3 -72
- package/tx/html/tx-template.html +2 -0
- package/tx/library/canonical-resource.js +50 -1
- package/tx/library/codesystem.js +1 -0
- package/tx/library/conceptmap.js +1 -0
- package/tx/library/valueset.js +16 -0
- package/tx/library.js +17 -2
- package/tx/openapi-operations.js +363 -0
- package/tx/openapi-schemas.config.js +118 -0
- package/tx/openapi-schemas.json +7903 -0
- package/tx/openapi.js +289 -0
- package/tx/openapi.yaml +151 -0
- package/tx/operation-context.js +15 -0
- package/tx/params.js +3 -12
- package/tx/provider.js +10 -1
- package/tx/sct/editions.js +70 -0
- package/tx/tx-html.js +6 -0
- package/tx/tx.js +55 -0
- package/tx/vs/vs-database.js +14 -5
- package/tx/vs/vs-package.js +10 -0
- package/tx/workers/expand.js +37 -18
- package/tx/workers/metadata.js +0 -13
- package/tx/workers/translate.js +25 -1
- package/tx/workers/validate.js +74 -24
- package/tx/workers/worker.js +32 -43
- package/utilities/extract-schema-json.js +167 -0
- package/utilities/generate-openapi-schemas.js +49 -0
- package/utilities/submit-test-report.js +1 -1
- 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
|
|
package/config-template.json
CHANGED
|
@@ -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 };
|
package/library/html-server.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/library/languages.js
CHANGED
|
@@ -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
|
/**
|