fhirsmith 0.13.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -1
- package/README.md +48 -4
- package/config-template.json +70 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/nginx.md +391 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher.js +146 -14
- package/server.js +51 -14
- package/stats.js +19 -13
- package/testing/readme.md +112 -0
- package/testing/render.js +482 -0
- package/testing/search.js +378 -0
- package/testing/store.js +266 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +486 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +142 -27
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/extensions.js +2 -2
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +111 -15
- package/tx/provider.js +85 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/sct/text-index.js +419 -0
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +20 -0
- package/tx/workers/metadata.js +29 -4
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +167 -20
- package/tx/workers/validate.js +61 -182
- package/tx/workers/worker.js +9 -0
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
|
|
3
|
+
<html xml:lang="en" lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<title>FHIRsmith: [%title%]</title>
|
|
6
|
+
|
|
7
|
+
<meta charset="utf-8"/>
|
|
8
|
+
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
|
9
|
+
<meta content="http://hl7.org/fhir" name="author"/>
|
|
10
|
+
<meta charset="utf-8" http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
11
|
+
|
|
12
|
+
<link rel="stylesheet" href="/fhir.css"/>
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
<!-- Bootstrap core CSS -->
|
|
16
|
+
<link rel="stylesheet" href="/assets/css/bootstrap.css"/>
|
|
17
|
+
<link rel="stylesheet" href="/assets/css/bootstrap-fhir.css"/>
|
|
18
|
+
|
|
19
|
+
<!-- Project extras -->
|
|
20
|
+
<link rel="stylesheet" href="/assets/css/project.css"/>
|
|
21
|
+
<link rel="stylesheet" href="/assets/css/pygments-manni.css"/>
|
|
22
|
+
|
|
23
|
+
<!-- HTML5 shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
|
24
|
+
<!-- [if lt IE 9]>
|
|
25
|
+
<script src="/assets/js/html5shiv.js"></script>
|
|
26
|
+
<script src="/assets/js/respond.min.js"></script>
|
|
27
|
+
<![endif] -->
|
|
28
|
+
|
|
29
|
+
<!-- Favicons -->
|
|
30
|
+
<link sizes="144x144" rel="apple-touch-icon-precomposed" href="/assets/ico/apple-touch-icon-144-precomposed.png"/>
|
|
31
|
+
<link sizes="114x114" rel="apple-touch-icon-precomposed" href="/assets/ico/apple-touch-icon-114-precomposed.png"/>
|
|
32
|
+
<link sizes="72x72" rel="apple-touch-icon-precomposed" href="/assets/ico/apple-touch-icon-72-precomposed.png"/>
|
|
33
|
+
<link rel="apple-touch-icon-precomposed" href="/assets/ico/apple-touch-icon-57-precomposed.png"/>
|
|
34
|
+
<link rel="shortcut icon" href="/assets/ico/favicon.png"/>
|
|
35
|
+
<script type="text/javascript" src="/assets/js/json2.js"></script>
|
|
36
|
+
<script type="text/javascript" src="/assets/js/statuspage.js"></script>
|
|
37
|
+
<script type="text/javascript" src="/assets/js/jquery.min.js"></script>
|
|
38
|
+
<script type="text/javascript" src="/assets/js/jquery-ui.min.js"></script>
|
|
39
|
+
<link rel="stylesheet" href="/assets/css/jquery.ui.all.css">
|
|
40
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.core.js"></script>
|
|
41
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.widget.js"></script>
|
|
42
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.mouse.js"></script>
|
|
43
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.resizable.js"></script>
|
|
44
|
+
<script type="text/javascript" src="/assets/js/jquery.ui.draggable.js"></script>
|
|
45
|
+
<script type="text/javascript" src="/assets/js/jtip.js"></script>
|
|
46
|
+
<script type="text/javascript" src="/assets/js/jcookie.js"></script>
|
|
47
|
+
<script type="text/javascript" src="/assets/js/fhir-gw.js"></script>
|
|
48
|
+
</head>
|
|
49
|
+
|
|
50
|
+
<body>
|
|
51
|
+
|
|
52
|
+
<div id="segment-navbar" class="segment"> <!-- segment-breadcrumb -->
|
|
53
|
+
<div id="stripe"> </div>
|
|
54
|
+
<div class="container"> <!-- container -->
|
|
55
|
+
<div style="background-color: #ad1f2f; padding: 6px; color: white;"> <!-- container -->
|
|
56
|
+
|
|
57
|
+
<a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> © HL7.org |
|
|
58
|
+
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] |
|
|
59
|
+
<a href="/" style="color: gold"> Server Home</a> |
|
|
60
|
+
<a href="/testing" style="color: gold">Test Reports</a> |
|
|
61
|
+
<a href="/testing/summary" style="color: gold">Summary</a> |
|
|
62
|
+
</div> <!-- /container -->
|
|
63
|
+
</div> <!-- /container -->
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- /segment-breadcrumb -->
|
|
67
|
+
|
|
68
|
+
<div id="segment-content" class="segment"> <!-- segment-content -->
|
|
69
|
+
<div class="container"> <!-- container -->
|
|
70
|
+
<div class="row">
|
|
71
|
+
<div class="inner-wrapper">
|
|
72
|
+
<div class="col-12">
|
|
73
|
+
|
|
74
|
+
<h2><img border="0" src="/FHIRsmith32.png" style="vertical-align: text-bottom"/> [%title%] </h2>
|
|
75
|
+
|
|
76
|
+
[%content%]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
</div> <!-- /inner-wrapper -->
|
|
83
|
+
</div> <!-- /row -->
|
|
84
|
+
</div> <!-- /container -->
|
|
85
|
+
</div> <!-- /segment-content -->
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
<div id="segment-footer" class="segment"> <!-- segment-footer -->
|
|
89
|
+
<div class="container"> <!-- container -->
|
|
90
|
+
<div class="inner-wrapper">
|
|
91
|
+
<p>
|
|
92
|
+
<a href="http://www.hl7.org/fhir" style="color: gold" title="Fast Healthcare Interoperability Resources - Home Page"><img border="0" src="/icon-fhir-16.png" style="vertical-align: text-bottom"/> <b>FHIR</b></a> © HL7.org 2011+. |
|
|
93
|
+
<a href="https://github.com/HealthIntersections/FHIRsmith/blob/main/README.md" style="color: gold"><img border="0" src="/FHIRsmith16.png" style="vertical-align: text-bottom"/> FHIRsmith</a> [%ver%] © HealthIntersections.com.au 2023+ |
|
|
94
|
+
([%ms%] ms)
|
|
95
|
+
[%sponsorMessage%]
|
|
96
|
+
</p>
|
|
97
|
+
</div> <!-- /inner-wrapper -->
|
|
98
|
+
</div> <!-- /container -->
|
|
99
|
+
</div> <!-- /segment-footer -->
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
<div id="segment-post-footer" class="segment hidden"> <!-- segment-post-footer -->
|
|
103
|
+
<div class="container"> <!-- container -->
|
|
104
|
+
</div> <!-- /container -->
|
|
105
|
+
</div> <!-- /segment-post-footer -->
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
<!-- JS and analytics only. -->
|
|
112
|
+
<!-- Bootstrap core JavaScript
|
|
113
|
+
================================================== -->
|
|
114
|
+
<!-- Placed at the end of the document so the pages load faster -->
|
|
115
|
+
<!-- <script src="/assets/js/jquery.js"/> -->
|
|
116
|
+
<script src="/assets/js/bootstrap.min.js"/>
|
|
117
|
+
<script src="/assets/js/respond.min.js"/>
|
|
118
|
+
|
|
119
|
+
<script src="/assets/js/fhir.js"/>
|
|
120
|
+
|
|
121
|
+
<!-- Analytics Below
|
|
122
|
+
================================================== -->
|
|
123
|
+
|
|
124
|
+
</body>
|
|
125
|
+
|
|
126
|
+
</html>
|
|
@@ -0,0 +1,486 @@
|
|
|
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
|
+
* The /testing module: a place to send TestReports (from TxTester, and anything else),
|
|
9
|
+
* and to look at them.
|
|
10
|
+
*
|
|
11
|
+
* POST {base}/TestReport create (token required if one is configured)
|
|
12
|
+
* GET {base}/TestReport search - a Bundle, or HTML for a browser
|
|
13
|
+
* GET {base}/TestReport/:id read - JSON, or the rendered report for a browser
|
|
14
|
+
* DELETE {base}/TestReport/:id admin only (adminToken)
|
|
15
|
+
* GET {base}/metadata CapabilityStatement
|
|
16
|
+
* GET {base} the filterable list (HTML)
|
|
17
|
+
* GET {base}/summary latest result per test script x participant (HTML)
|
|
18
|
+
*
|
|
19
|
+
* R4 and R5 TestReports are treated as the same thing. Reports can't be changed once
|
|
20
|
+
* received: each POST is a new report, even when it's the same report as before.
|
|
21
|
+
*
|
|
22
|
+
* @module testing/testing
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const express = require('express');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
const rateLimit = require('express-rate-limit');
|
|
29
|
+
const escape = require('escape-html');
|
|
30
|
+
|
|
31
|
+
const folders = require('../library/folder-setup');
|
|
32
|
+
const htmlServer = require('../library/html-server');
|
|
33
|
+
const { tokenMatches, tokenConfigured } = require('../library/request-token');
|
|
34
|
+
const packageJson = require('../package.json');
|
|
35
|
+
const { TestReportStore } = require('./store');
|
|
36
|
+
const { parseSearch, dateRange, capabilitySearchParams } = require('./search');
|
|
37
|
+
const { renderList, renderSummary, renderReport, listQueryToSearch } = require('./render');
|
|
38
|
+
|
|
39
|
+
const TEMPLATE = 'testing';
|
|
40
|
+
const DEFAULT_MAX_SIZE = 500 * 1024;
|
|
41
|
+
const DEFAULT_TOKEN_HEADER = 'Authorization';
|
|
42
|
+
const FHIR_JSON = 'application/fhir+json; charset=utf-8';
|
|
43
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What's wrong with a submitted resource, if anything.
|
|
47
|
+
*
|
|
48
|
+
* @param {*} r - the parsed JSON
|
|
49
|
+
* @returns {string[]} problems; empty if it's acceptable
|
|
50
|
+
*/
|
|
51
|
+
function validateReport(r) {
|
|
52
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
53
|
+
return ['The body must be a JSON object'];
|
|
54
|
+
}
|
|
55
|
+
if (r.resourceType !== 'TestReport') {
|
|
56
|
+
return [`The resource must be a TestReport, not ${typeof r.resourceType === 'string' ? r.resourceType : 'something without a resourceType'}`];
|
|
57
|
+
}
|
|
58
|
+
const problems = [];
|
|
59
|
+
const needString = (name) => {
|
|
60
|
+
if (typeof r[name] !== 'string' || r[name].trim() === '') {
|
|
61
|
+
problems.push(`TestReport.${name} is required`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
['name', 'status', 'result', 'tester', 'issued'].forEach(needString);
|
|
65
|
+
if (typeof r.issued === 'string' && r.issued.trim() !== '' && !dateRange(r.issued)) {
|
|
66
|
+
problems.push(`TestReport.issued is not a valid dateTime: '${r.issued}'`);
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(r.participant) || r.participant.length === 0) {
|
|
69
|
+
problems.push('TestReport.participant is required (at least one)');
|
|
70
|
+
} else {
|
|
71
|
+
r.participant.forEach((p, i) => {
|
|
72
|
+
if (p === null || typeof p !== 'object' || typeof p.uri !== 'string' || p.uri.trim() === '') {
|
|
73
|
+
problems.push(`TestReport.participant[${i}].uri is required`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (r.score !== undefined && (typeof r.score !== 'number' || !Number.isFinite(r.score))) {
|
|
78
|
+
problems.push('TestReport.score must be a number');
|
|
79
|
+
}
|
|
80
|
+
if (r.meta !== undefined && (r.meta === null || typeof r.meta !== 'object' || Array.isArray(r.meta))) {
|
|
81
|
+
problems.push('TestReport.meta must be an object');
|
|
82
|
+
}
|
|
83
|
+
return problems;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function operationOutcome(severity, code, messages) {
|
|
87
|
+
return {
|
|
88
|
+
resourceType: 'OperationOutcome',
|
|
89
|
+
issue: (Array.isArray(messages) ? messages : [messages]).map(m => ({
|
|
90
|
+
severity, code, details: { text: m }
|
|
91
|
+
}))
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function preferValue(req, name) {
|
|
96
|
+
const prefer = req.get('Prefer') || '';
|
|
97
|
+
for (const part of prefer.split(/[,;]/)) {
|
|
98
|
+
const [k, v] = part.split('=').map(s => s && s.trim());
|
|
99
|
+
if (k === name) {
|
|
100
|
+
return v ? v.replace(/^"|"$/g, '') : '';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function wantsHtml(req) {
|
|
107
|
+
const f = req.query._format;
|
|
108
|
+
if (typeof f === 'string' && f !== '') {
|
|
109
|
+
return /html/i.test(f);
|
|
110
|
+
}
|
|
111
|
+
return (req.get('Accept') || '').includes('text/html');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class TestingModule {
|
|
115
|
+
/**
|
|
116
|
+
* @param {Object} stats - a ModuleStats
|
|
117
|
+
* @param {Object} [log] - a logger; defaults to the server's
|
|
118
|
+
*/
|
|
119
|
+
constructor(stats, log) {
|
|
120
|
+
this.stats = stats;
|
|
121
|
+
this.router = express.Router();
|
|
122
|
+
this.store = null;
|
|
123
|
+
this.config = null;
|
|
124
|
+
this.retentionTimer = null;
|
|
125
|
+
if (log) {
|
|
126
|
+
this.log = log;
|
|
127
|
+
} else {
|
|
128
|
+
const Logger = require('../library/logger');
|
|
129
|
+
this.log = Logger.getInstance().child({ module: 'testing' });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async initialize(config) {
|
|
134
|
+
this.config = config || {};
|
|
135
|
+
const db = this.config.database || 'testing.db';
|
|
136
|
+
const dbPath = db === ':memory:' || path.isAbsolute(db) ? db : path.join(folders.databasesDir(), db);
|
|
137
|
+
this.store = new TestReportStore(dbPath);
|
|
138
|
+
this.maxSize = Number.isInteger(this.config.maxSize) && this.config.maxSize > 0 ? this.config.maxSize : DEFAULT_MAX_SIZE;
|
|
139
|
+
this.tokenHeader = this.config.tokenHeader || DEFAULT_TOKEN_HEADER;
|
|
140
|
+
|
|
141
|
+
htmlServer.loadTemplate(TEMPLATE, path.join(__dirname, 'testing-template.html'));
|
|
142
|
+
|
|
143
|
+
this.setupRoutes();
|
|
144
|
+
this.setupRetention();
|
|
145
|
+
this.log.info(`Testing module: ${this.store.count()} reports in ${dbPath}; ` +
|
|
146
|
+
`posting ${tokenConfigured(this.config.token) ? 'requires a token' : 'is open'}, max ${this.maxSize} bytes`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setupRoutes() {
|
|
150
|
+
const r = this.router;
|
|
151
|
+
const rl = this.config.rateLimit || {};
|
|
152
|
+
const max = rl.max === undefined ? 60 : rl.max;
|
|
153
|
+
const posting = [];
|
|
154
|
+
if (max > 0) {
|
|
155
|
+
posting.push(rateLimit({
|
|
156
|
+
windowMs: (rl.windowMinutes || 1) * 60 * 1000,
|
|
157
|
+
limit: max,
|
|
158
|
+
standardHeaders: 'draft-7',
|
|
159
|
+
legacyHeaders: false,
|
|
160
|
+
handler: (req, res) => this.sendOutcome(res, 429, 'throttled', 'Too many reports submitted; try again later')
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
r.get('/', (req, res) => this.handle(req, res, 'list', () => this.htmlList(req, res)));
|
|
165
|
+
r.get('/summary', (req, res) => this.handle(req, res, 'summary', () => this.htmlSummary(req, res)));
|
|
166
|
+
r.get('/metadata', (req, res) => this.handle(req, res, 'metadata', () => this.metadata(req, res)));
|
|
167
|
+
r.post('/TestReport', ...posting, (req, res) => this.handle(req, res, 'create', () => this.create(req, res)));
|
|
168
|
+
r.get('/TestReport', (req, res) => this.handle(req, res, 'search', () =>
|
|
169
|
+
wantsHtml(req) ? this.htmlList(req, res) : this.search(req, res)));
|
|
170
|
+
r.get('/TestReport/:id', (req, res) => this.handle(req, res, 'read', () => this.read(req, res)));
|
|
171
|
+
r.delete('/TestReport/:id', (req, res) => this.handle(req, res, 'delete', () => this.remove(req, res)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setupRetention() {
|
|
175
|
+
const days = this.config.retentionDays;
|
|
176
|
+
if (!(typeof days === 'number' && days > 0)) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const purge = () => {
|
|
180
|
+
try {
|
|
181
|
+
const n = this.store.purgeBefore(Date.now() - days * DAY_MS);
|
|
182
|
+
if (n > 0) {
|
|
183
|
+
this.log.info(`Testing module: deleted ${n} reports older than ${days} days`);
|
|
184
|
+
}
|
|
185
|
+
if (this.stats) {
|
|
186
|
+
this.stats.taskDone('TestReport retention', `${n} deleted`);
|
|
187
|
+
}
|
|
188
|
+
} catch (e) {
|
|
189
|
+
this.log.error('Testing module: retention purge failed: ' + e.message);
|
|
190
|
+
if (this.stats) {
|
|
191
|
+
this.stats.taskError('TestReport retention', e.message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
if (this.stats) {
|
|
196
|
+
this.stats.addTask('TestReport retention', 'daily');
|
|
197
|
+
}
|
|
198
|
+
purge();
|
|
199
|
+
this.retentionTimer = setInterval(purge, DAY_MS);
|
|
200
|
+
this.retentionTimer.unref();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async shutdown() {
|
|
204
|
+
if (this.retentionTimer) {
|
|
205
|
+
clearInterval(this.retentionTimer);
|
|
206
|
+
this.retentionTimer = null;
|
|
207
|
+
}
|
|
208
|
+
if (this.store) {
|
|
209
|
+
this.store.close();
|
|
210
|
+
this.store = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- plumbing ----
|
|
215
|
+
|
|
216
|
+
async handle(req, res, name, fn) {
|
|
217
|
+
const start = Date.now();
|
|
218
|
+
try {
|
|
219
|
+
await fn();
|
|
220
|
+
} catch (e) {
|
|
221
|
+
this.log.error(`Testing module: ${name} failed: ${e.stack || e.message}`);
|
|
222
|
+
if (!res.headersSent) {
|
|
223
|
+
if (wantsHtml(req)) {
|
|
224
|
+
htmlServer.sendErrorResponse(res, TEMPLATE, e);
|
|
225
|
+
} else {
|
|
226
|
+
this.sendOutcome(res, 500, 'exception', 'Internal error: ' + e.message);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
if (this.stats) {
|
|
231
|
+
this.stats.countRequest(name, Date.now() - start);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
baseUrl(req) {
|
|
237
|
+
return `${req.protocol}://${req.get('host')}${req.baseUrl}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
sendJson(res, status, body) {
|
|
241
|
+
res.status(status).set('Content-Type', FHIR_JSON).send(JSON.stringify(body, null, 2));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
sendOutcome(res, status, code, messages) {
|
|
245
|
+
const severity = status < 400 ? 'information' : 'error';
|
|
246
|
+
this.sendJson(res, status, operationOutcome(severity, code, messages));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
sendHtml(res, title, content, start) {
|
|
250
|
+
htmlServer.sendHtmlResponse(res, TEMPLATE, title, content, {
|
|
251
|
+
version: packageJson.version,
|
|
252
|
+
processingTime: Date.now() - start
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The token from the configured header, without any "Bearer " prefix. */
|
|
257
|
+
suppliedToken(req) {
|
|
258
|
+
const v = req.get(this.tokenHeader);
|
|
259
|
+
return typeof v === 'string' ? v.replace(/^Bearer\s+/i, '').trim() : v;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- FHIR API ----
|
|
263
|
+
|
|
264
|
+
async create(req, res) {
|
|
265
|
+
if (tokenConfigured(this.config.token) && !tokenMatches(this.config.token, this.suppliedToken(req))) {
|
|
266
|
+
res.set('WWW-Authenticate', 'Bearer');
|
|
267
|
+
return this.sendOutcome(res, 401, 'login', `A valid token is required in the ${this.tokenHeader} header`);
|
|
268
|
+
}
|
|
269
|
+
if (!req.is('application/fhir+json') && !req.is('application/json')) {
|
|
270
|
+
return this.sendOutcome(res, 415, 'not-supported', 'Reports must be JSON (application/fhir+json)');
|
|
271
|
+
}
|
|
272
|
+
const declared = parseInt(req.get('Content-Length') || '', 10);
|
|
273
|
+
if (declared > this.maxSize) {
|
|
274
|
+
return this.sendOutcome(res, 413, 'too-costly', `Reports can't be bigger than ${this.maxSize} bytes`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// the server's body parsers have already run: application/fhir+json arrives as a
|
|
278
|
+
// Buffer, application/json already parsed
|
|
279
|
+
let report;
|
|
280
|
+
let size;
|
|
281
|
+
if (Buffer.isBuffer(req.body)) {
|
|
282
|
+
size = req.body.length;
|
|
283
|
+
if (size > this.maxSize) {
|
|
284
|
+
return this.sendOutcome(res, 413, 'too-costly', `Reports can't be bigger than ${this.maxSize} bytes`);
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
report = JSON.parse(req.body.toString('utf8'));
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return this.sendOutcome(res, 400, 'structure', 'The body is not valid JSON: ' + e.message);
|
|
290
|
+
}
|
|
291
|
+
} else if (req.body && typeof req.body === 'object') {
|
|
292
|
+
report = req.body;
|
|
293
|
+
size = Buffer.byteLength(JSON.stringify(report), 'utf8');
|
|
294
|
+
if (size > this.maxSize) {
|
|
295
|
+
return this.sendOutcome(res, 413, 'too-costly', `Reports can't be bigger than ${this.maxSize} bytes`);
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
return this.sendOutcome(res, 400, 'structure', 'No report was found in the body');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const problems = validateReport(report);
|
|
302
|
+
if (problems.length > 0) {
|
|
303
|
+
return this.sendOutcome(res, 400, 'invalid', problems);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// id and lastUpdated are the server's; there are no versions
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
const lastUpdated = new Date(now).toISOString();
|
|
309
|
+
const meta = { ...(report.meta || {}), lastUpdated };
|
|
310
|
+
delete meta.versionId;
|
|
311
|
+
const stored = { resourceType: report.resourceType, id: crypto.randomUUID(), meta };
|
|
312
|
+
for (const [k, v] of Object.entries(report)) {
|
|
313
|
+
if (!(k in stored)) {
|
|
314
|
+
stored[k] = v;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
this.store.insert(stored, { ip: req.ip, receivedMs: now });
|
|
318
|
+
|
|
319
|
+
const location = `${this.baseUrl(req)}/TestReport/${stored.id}`;
|
|
320
|
+
res.set('Location', location);
|
|
321
|
+
res.set('Last-Modified', new Date(now).toUTCString());
|
|
322
|
+
const ret = preferValue(req, 'return');
|
|
323
|
+
if (ret === 'minimal') {
|
|
324
|
+
return res.status(201).end();
|
|
325
|
+
}
|
|
326
|
+
if (ret === 'OperationOutcome') {
|
|
327
|
+
return this.sendOutcome(res, 201, 'informational', `Report stored as ${location}`);
|
|
328
|
+
}
|
|
329
|
+
return this.sendJson(res, 201, stored);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async read(req, res) {
|
|
333
|
+
const start = Date.now();
|
|
334
|
+
const report = this.store.read(req.params.id);
|
|
335
|
+
if (wantsHtml(req)) {
|
|
336
|
+
if (!report) {
|
|
337
|
+
return htmlServer.sendErrorResponse(res, TEMPLATE, new Error(`No report with id '${req.params.id}'`), 404);
|
|
338
|
+
}
|
|
339
|
+
return this.sendHtml(res, 'Test Report: ' + (typeof report.name === 'string' ? report.name : report.id),
|
|
340
|
+
renderReport(report, req.baseUrl), start);
|
|
341
|
+
}
|
|
342
|
+
if (!report) {
|
|
343
|
+
return this.sendOutcome(res, 404, 'not-found', `No TestReport with id '${req.params.id}'`);
|
|
344
|
+
}
|
|
345
|
+
res.set('Last-Modified', new Date(report.meta.lastUpdated).toUTCString());
|
|
346
|
+
return this.sendJson(res, 200, report);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async remove(req, res) {
|
|
350
|
+
if (!tokenConfigured(this.config.adminToken) || !tokenMatches(this.config.adminToken, this.suppliedToken(req))) {
|
|
351
|
+
return this.sendOutcome(res, 403, 'forbidden', 'Deleting reports requires the admin token');
|
|
352
|
+
}
|
|
353
|
+
if (!this.store.delete(req.params.id)) {
|
|
354
|
+
return this.sendOutcome(res, 404, 'not-found', `No TestReport with id '${req.params.id}'`);
|
|
355
|
+
}
|
|
356
|
+
this.log.info(`Testing module: report ${req.params.id} deleted by admin`);
|
|
357
|
+
return res.status(204).end();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async search(req, res) {
|
|
361
|
+
const search = parseSearch(req.query);
|
|
362
|
+
if (search.errors.length > 0) {
|
|
363
|
+
return this.sendOutcome(res, 400, 'invalid', search.errors);
|
|
364
|
+
}
|
|
365
|
+
if (search.unknown.length > 0 && preferValue(req, 'handling') === 'strict') {
|
|
366
|
+
return this.sendOutcome(res, 400, 'not-supported', search.unknown.map(u => `Unknown search parameter '${u}'`));
|
|
367
|
+
}
|
|
368
|
+
const results = this.store.search(search, { withJson: true });
|
|
369
|
+
const base = this.baseUrl(req);
|
|
370
|
+
|
|
371
|
+
const link = (offset) => {
|
|
372
|
+
const u = new URLSearchParams();
|
|
373
|
+
for (const [k, v] of search.used) {
|
|
374
|
+
u.append(k, v);
|
|
375
|
+
}
|
|
376
|
+
if (search.sort) {
|
|
377
|
+
u.append('_sort', search.sort);
|
|
378
|
+
}
|
|
379
|
+
if (search.summaryCount) {
|
|
380
|
+
u.append('_summary', 'count');
|
|
381
|
+
} else {
|
|
382
|
+
u.append('_count', String(search.count));
|
|
383
|
+
u.append('_offset', String(offset));
|
|
384
|
+
}
|
|
385
|
+
return `${base}/TestReport?${u.toString()}`;
|
|
386
|
+
};
|
|
387
|
+
const links = [{ relation: 'self', url: link(search.offset) }];
|
|
388
|
+
if (!search.summaryCount && search.count > 0) {
|
|
389
|
+
links.push({ relation: 'first', url: link(0) });
|
|
390
|
+
if (search.offset > 0) {
|
|
391
|
+
links.push({ relation: 'previous', url: link(Math.max(0, search.offset - search.count)) });
|
|
392
|
+
}
|
|
393
|
+
if (search.offset + search.count < results.total) {
|
|
394
|
+
links.push({ relation: 'next', url: link(search.offset + search.count) });
|
|
395
|
+
}
|
|
396
|
+
links.push({ relation: 'last', url: link(results.total === 0 ? 0 : Math.floor((results.total - 1) / search.count) * search.count) });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const bundle = {
|
|
400
|
+
resourceType: 'Bundle',
|
|
401
|
+
id: crypto.randomUUID(),
|
|
402
|
+
meta: { lastUpdated: new Date().toISOString() },
|
|
403
|
+
type: 'searchset',
|
|
404
|
+
total: results.total,
|
|
405
|
+
link: links,
|
|
406
|
+
entry: results.rows.map(row => ({
|
|
407
|
+
fullUrl: `${base}/TestReport/${row.id}`,
|
|
408
|
+
resource: JSON.parse(row.json),
|
|
409
|
+
search: { mode: 'match' }
|
|
410
|
+
}))
|
|
411
|
+
};
|
|
412
|
+
if (search.unknown.length > 0) {
|
|
413
|
+
bundle.entry.push({
|
|
414
|
+
resource: operationOutcome('warning', 'not-supported', search.unknown.map(u => `Unknown search parameter '${u}' was ignored`)),
|
|
415
|
+
search: { mode: 'outcome' }
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
if (bundle.entry.length === 0) {
|
|
419
|
+
delete bundle.entry;
|
|
420
|
+
}
|
|
421
|
+
return this.sendJson(res, 200, bundle);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
metadata(req, res) {
|
|
425
|
+
const cs = {
|
|
426
|
+
resourceType: 'CapabilityStatement',
|
|
427
|
+
status: 'active',
|
|
428
|
+
date: new Date().toISOString(),
|
|
429
|
+
kind: 'instance',
|
|
430
|
+
software: { name: 'FHIRsmith', version: packageJson.version },
|
|
431
|
+
implementation: {
|
|
432
|
+
description: 'FHIRsmith TestReport repository',
|
|
433
|
+
url: this.baseUrl(req)
|
|
434
|
+
},
|
|
435
|
+
fhirVersion: '5.0.0',
|
|
436
|
+
format: ['json'],
|
|
437
|
+
rest: [{
|
|
438
|
+
mode: 'server',
|
|
439
|
+
documentation: tokenConfigured(this.config.token)
|
|
440
|
+
? `Submitting a report requires a token in the ${this.tokenHeader} header`
|
|
441
|
+
: 'Anyone may submit a report',
|
|
442
|
+
resource: [{
|
|
443
|
+
type: 'TestReport',
|
|
444
|
+
interaction: [{ code: 'create' }, { code: 'read' }, { code: 'search-type' }],
|
|
445
|
+
versioning: 'no-version',
|
|
446
|
+
readHistory: false,
|
|
447
|
+
updateCreate: false,
|
|
448
|
+
searchParam: capabilitySearchParams()
|
|
449
|
+
}]
|
|
450
|
+
}]
|
|
451
|
+
};
|
|
452
|
+
return this.sendJson(res, 200, cs);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---- HTML ----
|
|
456
|
+
|
|
457
|
+
async htmlList(req, res) {
|
|
458
|
+
const start = Date.now();
|
|
459
|
+
const query = req.query || {};
|
|
460
|
+
const search = parseSearch(listQueryToSearch(query));
|
|
461
|
+
let prefix = '';
|
|
462
|
+
if (search.errors.length > 0) {
|
|
463
|
+
prefix = '<div class="alert alert-warning">' + search.errors.map(e => escape(e)).join('<br/>') + '</div>';
|
|
464
|
+
// show everything rather than nothing
|
|
465
|
+
const fallback = parseSearch({ _count: query._count });
|
|
466
|
+
search.where = fallback.where;
|
|
467
|
+
search.args = fallback.args;
|
|
468
|
+
}
|
|
469
|
+
const results = this.store.search(search);
|
|
470
|
+
const content = prefix + renderList(query, results, search, {
|
|
471
|
+
statuses: this.store.distinct('status'),
|
|
472
|
+
results: this.store.distinct('result'),
|
|
473
|
+
base: req.baseUrl
|
|
474
|
+
});
|
|
475
|
+
this.sendHtml(res, 'Test Reports', content, start);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async htmlSummary(req, res) {
|
|
479
|
+
const start = Date.now();
|
|
480
|
+
const by = req.query.by === 'tester' ? 'tester' : 'participant';
|
|
481
|
+
this.sendHtml(res, 'Test Report Summary', renderSummary(this.store.summary(by), by, req.baseUrl, this.store.testerCounts()), start);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
module.exports = TestingModule;
|
|
486
|
+
module.exports.validateReport = validateReport;
|
|
@@ -161,6 +161,7 @@ CODESYSTEM_THO_CHECK = Most code systems defined in HL7 IGs will need to move to
|
|
|
161
161
|
CODESYSTEM_TOO_COSTLY_TIME = The operation ''{1}'' took too long to process (>{0}sec) (at {2})
|
|
162
162
|
CODESYSTEM_NOT_ENUMERABLE = The code system ''{0}'' cannot be expanded because its codes cannot be iterated or enumerated in any meaningful sense
|
|
163
163
|
Coding_has_no_system__cannot_validate = Coding has no system. A code with no system has no defined meaning, and it cannot be validated. A system should be provided
|
|
164
|
+
Coding_has_no_system_or_code__cannot_validate = Coding has neither a system nor a code, so it cannot be validated. A system and a code should be provided
|
|
164
165
|
CONCEPTMAP_GROUP_SOURCE_CODE_INVALID = The source code ''{0}'' is not valid in the code system {1}
|
|
165
166
|
CONCEPTMAP_GROUP_SOURCE_CODE_INVALID_VS = The source code ''{0}'' is not valid in the value set {1}
|
|
166
167
|
CONCEPTMAP_GROUP_SOURCE_DISPLAY_INVALID = The source display ''{0}'' for the code ''{2}'' is not valid. Possible displays: {1}
|
|
@@ -514,6 +515,7 @@ MSG_DEPRECATED = Reference to deprecated {2} {0}
|
|
|
514
515
|
MSG_DEPRECATED_SRC = Reference to deprecated {2} {0} from {1}
|
|
515
516
|
MSG_DRAFT = Reference to draft {2} {0}
|
|
516
517
|
MSG_DRAFT_SRC = Reference to draft {2} {0} from {1}
|
|
518
|
+
MSG_DRAFT_SRC_STATUS = Reference to draft {2} {0} from {3} {4} {1}
|
|
517
519
|
MSG_EXPERIMENTAL = Reference to experimental {2} {0}
|
|
518
520
|
MSG_EXPERIMENTAL_SRC = Reference to experimental {2} {0} from {1}
|
|
519
521
|
MSG_RETIRED = Reference to retired {2} {0}
|
|
@@ -1106,6 +1108,36 @@ CACHE_ID_UNKNOWN = The cache ''{0}'' was never issued by this server, and this s
|
|
|
1106
1108
|
CACHE_ID_CLOSED = The cache ''{0}'' was closed {1} ago by the client, using $cache-control?mode=end (it had been open for {2}). It did not expire and it was not idle: something in the client released this cache while this request was still using it
|
|
1107
1109
|
CACHE_ID_EXPIRED = The cache ''{0}'' expired: it went {1} without being used, and this server closes caches that have been idle for more than {2} (this one was dropped {3} ago). Every request that uses a cache resets its idle clock, so a client that keeps using a cache will not see this
|
|
1108
1110
|
CACHE_ID_CLEARED = The cache ''{0}'' was dropped {1} ago, when this server cleared all of its caches
|
|
1111
|
+
CACHE_ID_OTHER_INSTANCE = The cache ''{0}'' was issued by the server instance ''{1}'', but this is instance ''{2}''. A cache only exists on the instance that issued it, so this request reached the wrong server: either the proxy in front of these servers is not routing on the cache-id, or instance ''{1}'' is unavailable
|
|
1112
|
+
CACHE_ID_OTHER_INSTANCE_UNNAMED = The cache ''{0}'' was issued by the server instance ''{1}'', but this server has no instance code, so it cannot have issued it. A cache only exists on the instance that issued it, so this request reached the wrong server: either the proxy in front of these servers is not routing on the cache-id, or instance ''{1}'' is unavailable
|
|
1113
|
+
CLOSURE_NOT_SUPPORTED = This server does not support $closure (it has to be turned on by the administrator)
|
|
1114
|
+
CLOSURE_POST_ONLY = $closure changes the closure table held on the server, so it must be invoked with POST
|
|
1115
|
+
CLOSURE_NAME_REQUIRED = $closure requires a ''name'' parameter naming the closure table
|
|
1116
|
+
CLOSURE_NAME_INVALID = The closure table name ''{0}'' is not valid: it must not contain white space, and must be no more than 64 characters long
|
|
1117
|
+
CLOSURE_NAME_NOT_UUID = The closure table name ''{0}'' is not valid: this server requires closure table names to be UUIDs
|
|
1118
|
+
CLOSURE_PARAM_UNKNOWN = The parameter ''{0}'' is not supported by $closure on this server
|
|
1119
|
+
CLOSURE_CONCEPT_AND_VERSION = A $closure request can add concepts or resynchronise from a version, but not both at once
|
|
1120
|
+
CLOSURE_RESET_AND_VERSION = A $closure request can reset the closure table or resynchronise from a version, but not both at once
|
|
1121
|
+
CLOSURE_TABLE_EXISTS = The closure table ''{0}'' already exists. To discard its content and start again, include the parameter reset = true
|
|
1122
|
+
CLOSURE_TABLE_UNKNOWN = The closure table ''{0}'' does not exist on this server. Initialise it first, with a $closure request that names it and has no concepts
|
|
1123
|
+
CLOSURE_CONFIG_NOT_ALLOWED = The parameter ''{0}'' configures a closure table, so it can only be supplied when the table is initialised (or reset)
|
|
1124
|
+
CLOSURE_VERSION_INVALID = The version ''{0}'' is not valid for the closure table ''{1}'': it must be a whole number between 0 and the table''s current version, {2}
|
|
1125
|
+
CLOSURE_CONCEPT_INVALID = Each concept added to a closure table must be a Coding with a system and a code
|
|
1126
|
+
CLOSURE_TOO_MANY_TABLES = This server holds at most {0} closure tables, and has no room for another
|
|
1127
|
+
CLOSURE_TOO_MANY_CONCEPTS = The closure table ''{0}'' can hold at most {1} concepts on this server
|
|
1128
|
+
CLOSURE_TOO_MANY_REQUEST_CONCEPTS = A single $closure request can add at most {0} concepts on this server
|
|
1129
|
+
CLOSURE_TOO_MANY_ENTRIES = Adding these concepts would put more entries into the closure table ''{0}'' than this server allows, in one request or in total
|
|
1130
|
+
CLOSURE_CONFIG_TOO_LARGE = The configuration supplied for this closure table (mostly the tx-resource parameters) is larger than this server will keep, which is {0} bytes
|
|
1131
|
+
CLOSURE_TABLE_BUSY = The closure table ''{0}'' is busy with other requests. Try again shortly
|
|
1132
|
+
SUBSUMPTION_CS_HIERARCHY_MEANING = The CodeSystem ''{0}'' has hierarchyMeaning = {1}, so its hierarchy does not describe subsumption, and subsumption cannot be tested
|
|
1133
|
+
SUBSUMPTION_CS_NOT_COMPLETE = The CodeSystem ''{0}'' has content = {1}, so subsumption cannot be tested (subsumption can only be tested against a code system with content = complete)
|
|
1134
|
+
SUBSUMPTION_CS_NOT_FOUND = A definition for the CodeSystem ''{0}'' could not be found, so subsumption cannot be tested
|
|
1135
|
+
SUBSUMPTION_CS_NOT_FOUND_VERSION = A definition for the CodeSystem ''{0}'' version ''{1}'' could not be found, so subsumption cannot be tested
|
|
1136
|
+
SUBSUMPTION_NO_CODE = No code is provided for {0}, so subsumption cannot be tested
|
|
1137
|
+
SUBSUMPTION_NO_CODING = No value is provided for {0}, so subsumption cannot be tested
|
|
1138
|
+
SUBSUMPTION_NO_SYSTEM = No code system is provided for {0}, so subsumption cannot be tested
|
|
1139
|
+
SUBSUMPTION_SYSTEM_MISMATCH = Subsumption can only be tested between two codes in the same code system, but the code systems are ''{0}'' and ''{1}''
|
|
1140
|
+
SUBSUMPTION_VERSION_MISMATCH = Subsumption can only be tested between two codes in the same version of ''{0}'', but the versions are ''{1}'' and ''{2}''
|
|
1109
1141
|
Unable_to_validate_code_without_using_server = Unable to validate code without using server because: {0}
|
|
1110
1142
|
Undefined_attribute__on__for_type__properties__ = Undefined attribute ''@{0}'' on {1} for type {2}
|
|
1111
1143
|
Undefined_element_ = Undefined element ''{0}'' at {1}
|