fhirsmith 0.14.1 → 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 +35 -0
- package/README.md +3 -2
- package/config-template.json +9 -0
- package/library/fhir-openapi-schema.js +441 -0
- package/library/html-server.js +9 -3
- package/library/npm-audit.js +265 -0
- package/library/openapi-doc.js +557 -0
- package/library/utilities.js +21 -0
- 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/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 +180 -19
- package/server.js +19 -5
- 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 +43 -1
- package/testing/search.js +1 -1
- package/testing/testing-template.html +2 -0
- package/testing/testing.js +24 -0
- 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/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/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 +35 -16
- package/tx/workers/metadata.js +0 -13
- package/tx/workers/validate.js +65 -19
- package/tx/workers/worker.js +29 -8
- package/utilities/extract-schema-json.js +167 -0
- package/utilities/generate-openapi-schemas.js +49 -0
- package/xig/xig.js +2 -4
|
@@ -0,0 +1,557 @@
|
|
|
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
|
+
// Serves a module's hand-maintained OpenAPI description: loads the YAML file, sets
|
|
8
|
+
// info.version from package.json, and renders it as an HTML reference page. Used by the
|
|
9
|
+
// package server (packages/openapi.yaml) and the terminology registry (registry/openapi.yaml);
|
|
10
|
+
// each module has its own, independent spec.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const YAML = require('yaml');
|
|
14
|
+
const escape = require('escape-html');
|
|
15
|
+
const commonmark = require('commonmark');
|
|
16
|
+
const packageJson = require('../package.json');
|
|
17
|
+
|
|
18
|
+
// patterns longer than this are collapsed
|
|
19
|
+
const PATTERN_INLINE = 40;
|
|
20
|
+
|
|
21
|
+
const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
22
|
+
|
|
23
|
+
function commonmarkHtml(text) {
|
|
24
|
+
const reader = new commonmark.Parser();
|
|
25
|
+
const writer = new commonmark.HtmlRenderer({ safe: true });
|
|
26
|
+
return writer.render(reader.parse(text));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// GitHub-style pipe tables, which CommonMark doesn't have (and the terminology server's
|
|
30
|
+
// operations use, for their parameters): a header row, a delimiter row, and the rows
|
|
31
|
+
const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
|
32
|
+
const TABLE_DELIMITER = /^\s*\|(\s*:?-+:?\s*\|)+\s*$/;
|
|
33
|
+
|
|
34
|
+
function tableCells(line) {
|
|
35
|
+
const cells = [];
|
|
36
|
+
let cell = '';
|
|
37
|
+
const s = line.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
38
|
+
for (let i = 0; i < s.length; i++) {
|
|
39
|
+
if (s[i] === '\\' && s[i + 1] === '\\') {
|
|
40
|
+
// an escaped backslash, left for CommonMark (so \\| is a backslash, then the next cell)
|
|
41
|
+
cell += '\\\\';
|
|
42
|
+
i++;
|
|
43
|
+
} else if (s[i] === '\\' && s[i + 1] === '|') {
|
|
44
|
+
cell += '|';
|
|
45
|
+
i++;
|
|
46
|
+
} else if (s[i] === '|') {
|
|
47
|
+
cells.push(cell.trim());
|
|
48
|
+
cell = '';
|
|
49
|
+
} else {
|
|
50
|
+
cell += s[i];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
cells.push(cell.trim());
|
|
54
|
+
return cells;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tableCellHtml(text) {
|
|
58
|
+
return commonmarkHtml(text).trim().replace(/^<p>([\s\S]*)<\/p>$/, '$1');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tableHtml(header, rows) {
|
|
62
|
+
let html = '<table class="table table-condensed"><thead><tr>';
|
|
63
|
+
html += header.map(h => `<th>${tableCellHtml(h)}</th>`).join('');
|
|
64
|
+
html += '</tr></thead><tbody>';
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
html += '<tr>' + header.map((h, i) => `<td>${tableCellHtml(row[i] || '')}</td>`).join('') + '</tr>';
|
|
67
|
+
}
|
|
68
|
+
return html + '</tbody></table>';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function markdown(text) {
|
|
72
|
+
if (!text) {
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
const lines = text.split('\n');
|
|
76
|
+
const out = [];
|
|
77
|
+
let buf = [];
|
|
78
|
+
const flush = () => {
|
|
79
|
+
if (buf.length > 0) {
|
|
80
|
+
out.push(commonmarkHtml(buf.join('\n')));
|
|
81
|
+
buf = [];
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
for (let i = 0; i < lines.length; i++) {
|
|
85
|
+
if (TABLE_ROW.test(lines[i]) && i + 1 < lines.length && TABLE_DELIMITER.test(lines[i + 1])) {
|
|
86
|
+
flush();
|
|
87
|
+
const header = tableCells(lines[i]);
|
|
88
|
+
const rows = [];
|
|
89
|
+
i += 2;
|
|
90
|
+
while (i < lines.length && TABLE_ROW.test(lines[i])) {
|
|
91
|
+
rows.push(tableCells(lines[i]));
|
|
92
|
+
i++;
|
|
93
|
+
}
|
|
94
|
+
i--;
|
|
95
|
+
out.push(tableHtml(header, rows));
|
|
96
|
+
} else {
|
|
97
|
+
buf.push(lines[i]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
flush();
|
|
101
|
+
return out.join('');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolves a local '#/components/...' reference.
|
|
105
|
+
function resolveRef(spec, obj) {
|
|
106
|
+
if (!obj || !obj.$ref) {
|
|
107
|
+
return obj;
|
|
108
|
+
}
|
|
109
|
+
let target = spec;
|
|
110
|
+
for (const part of obj.$ref.replace(/^#\//, '').split('/')) {
|
|
111
|
+
target = target ? target[part] : undefined;
|
|
112
|
+
}
|
|
113
|
+
return target;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function refName(ref) {
|
|
117
|
+
return ref.substring(ref.lastIndexOf('/') + 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function schemaLink(name) {
|
|
121
|
+
return `<a href="#schema-${escape(name)}"><code>${escape(name)}</code></a>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A one-line description of a schema: a link for a reference, otherwise its type and the
|
|
125
|
+
// constraints a client needs to know.
|
|
126
|
+
function describeSchema(schema) {
|
|
127
|
+
if (!schema) {
|
|
128
|
+
return '';
|
|
129
|
+
}
|
|
130
|
+
if (schema.$ref) {
|
|
131
|
+
return schemaLink(refName(schema.$ref));
|
|
132
|
+
}
|
|
133
|
+
if (schema.allOf) {
|
|
134
|
+
return schema.allOf.map(describeSchema).join(' + ');
|
|
135
|
+
}
|
|
136
|
+
if (schema.oneOf || schema.anyOf) {
|
|
137
|
+
return (schema.oneOf || schema.anyOf).map(describeSchema).join(' or ');
|
|
138
|
+
}
|
|
139
|
+
if (schema.const !== undefined) {
|
|
140
|
+
return `<code>${escape(JSON.stringify(schema.const))}</code>`;
|
|
141
|
+
}
|
|
142
|
+
if (schema.type === 'array') {
|
|
143
|
+
return `array of ${describeSchema(schema.items)}`;
|
|
144
|
+
}
|
|
145
|
+
if (schema.type === 'object' && schema.additionalProperties && !schema.properties) {
|
|
146
|
+
return `map of ${describeSchema(schema.additionalProperties)}`;
|
|
147
|
+
}
|
|
148
|
+
const parts = [`<code>${escape(schema.type || 'any')}</code>`];
|
|
149
|
+
if (schema['x-fhir-type']) {
|
|
150
|
+
parts.push(`(FHIR <code>${escape(schema['x-fhir-type'])}</code>)`);
|
|
151
|
+
}
|
|
152
|
+
if (schema.format) {
|
|
153
|
+
parts.push(`(${escape(schema.format)})`);
|
|
154
|
+
}
|
|
155
|
+
if (schema.enum) {
|
|
156
|
+
parts.push('one of ' + schema.enum.map(v => `<code>${escape(String(v))}</code>`).join(', '));
|
|
157
|
+
} else if (schema.pattern && schema.pattern.length <= PATTERN_INLINE) {
|
|
158
|
+
parts.push(`matching <code>${escape(schema.pattern)}</code>`);
|
|
159
|
+
} else if (schema.pattern) {
|
|
160
|
+
// long regexes (FHIR's dateTime one is over 200 characters) are shown on demand
|
|
161
|
+
parts.push(`<details class="pattern"><summary>pattern</summary><code>${escape(schema.pattern)}</code></details>`);
|
|
162
|
+
}
|
|
163
|
+
if (schema.maxLength) {
|
|
164
|
+
parts.push(`max ${schema.maxLength} chars`);
|
|
165
|
+
}
|
|
166
|
+
if (schema.default !== undefined) {
|
|
167
|
+
parts.push(`default <code>${escape(String(schema.default))}</code>`);
|
|
168
|
+
}
|
|
169
|
+
return parts.join(' ');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderParameters(spec, parameters) {
|
|
173
|
+
if (!parameters || parameters.length === 0) {
|
|
174
|
+
return '';
|
|
175
|
+
}
|
|
176
|
+
let html = '<h5>Parameters</h5><table class="table table-condensed"><thead><tr>' +
|
|
177
|
+
'<th>Name</th><th>In</th><th>Schema</th><th>Description</th></tr></thead><tbody>';
|
|
178
|
+
for (const p of parameters.map(p => resolveRef(spec, p))) {
|
|
179
|
+
html += '<tr>';
|
|
180
|
+
html += `<td><code>${escape(p.name)}</code>${p.required ? ' <span class="label label-default">required</span>' : ''}</td>`;
|
|
181
|
+
html += `<td>${escape(p.in)}</td>`;
|
|
182
|
+
html += `<td>${describeSchema(p.schema)}</td>`;
|
|
183
|
+
html += `<td>${markdown(p.description)}</td>`;
|
|
184
|
+
html += '</tr>';
|
|
185
|
+
}
|
|
186
|
+
return html + '</tbody></table>';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderResponses(spec, responses) {
|
|
190
|
+
let html = '<h5>Responses</h5><table class="table table-condensed"><thead><tr>' +
|
|
191
|
+
'<th>Status</th><th>Description</th><th>Content</th></tr></thead><tbody>';
|
|
192
|
+
for (const [status, r] of Object.entries(responses || {})) {
|
|
193
|
+
const response = resolveRef(spec, r);
|
|
194
|
+
const content = Object.entries(response.content || {})
|
|
195
|
+
.map(([type, media]) => `<code>${escape(type)}</code>: ${describeSchema(media.schema)}`)
|
|
196
|
+
.join('<br/>');
|
|
197
|
+
html += `<tr><td>${escape(status)}</td><td>${markdown(response.description)}</td><td>${content}</td></tr>`;
|
|
198
|
+
}
|
|
199
|
+
return html + '</tbody></table>';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderSchema(name, schema) {
|
|
203
|
+
let html = `<div class="openapi-schema" id="schema-${escape(name)}"><h4><code>${escape(name)}</code></h4>`;
|
|
204
|
+
html += markdown(schema.description);
|
|
205
|
+
if (schema.properties) {
|
|
206
|
+
const required = new Set(schema.required || []);
|
|
207
|
+
html += '<table class="table table-condensed"><thead><tr><th>Property</th><th>Schema</th><th>Description</th></tr></thead><tbody>';
|
|
208
|
+
for (const [prop, propSchema] of Object.entries(schema.properties)) {
|
|
209
|
+
html += '<tr>';
|
|
210
|
+
html += `<td><code>${escape(prop)}</code>${required.has(prop) ? ' <span class="label label-default">required</span>' : ''}</td>`;
|
|
211
|
+
html += `<td>${describeSchema(propSchema)}</td>`;
|
|
212
|
+
html += `<td>${markdown(propSchema.description)}</td>`;
|
|
213
|
+
html += '</tr>';
|
|
214
|
+
}
|
|
215
|
+
html += '</tbody></table>';
|
|
216
|
+
if (schema.additionalProperties) {
|
|
217
|
+
html += `<p>Other properties: ${describeSchema(schema.additionalProperties)}</p>`;
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
html += `<p>${describeSchema(schema)}</p>`;
|
|
221
|
+
}
|
|
222
|
+
return html + '</div>';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- "try it" ----
|
|
226
|
+
//
|
|
227
|
+
// Each GET operation gets a form built from its documented parameters; Send makes the request
|
|
228
|
+
// with fetch(), asking for the operation's JSON (not HTML - a browser following the link
|
|
229
|
+
// would get the web page), and shows the URL, the status, some headers, the body, and the
|
|
230
|
+
// same request as a curl command. Other methods get an example curl command only: trying a
|
|
231
|
+
// POST from a public page would create real resources.
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The request a try-it form describes. Runs in the browser (it's serialised into the page)
|
|
235
|
+
* and in the tests.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} pathTemplate - e.g. /packages/{id}/{version}
|
|
238
|
+
* @param {Array<{in: string, name: string, value: string}>} values - the form's fields
|
|
239
|
+
* @returns {{url: string, headers: Object, missing: string[]}}
|
|
240
|
+
*/
|
|
241
|
+
function buildTryItRequest(pathTemplate, values) {
|
|
242
|
+
const missing = [];
|
|
243
|
+
let url = pathTemplate;
|
|
244
|
+
const query = [];
|
|
245
|
+
const headers = {};
|
|
246
|
+
for (const v of values) {
|
|
247
|
+
const value = (v.value || '').trim();
|
|
248
|
+
if (v.in === 'path') {
|
|
249
|
+
if (!value) {
|
|
250
|
+
missing.push(v.name);
|
|
251
|
+
}
|
|
252
|
+
url = url.split('{' + v.name + '}').join(encodeURIComponent(value));
|
|
253
|
+
} else if (!value) {
|
|
254
|
+
continue;
|
|
255
|
+
} else if (v.in === 'query') {
|
|
256
|
+
query.push(encodeURIComponent(v.name) + '=' + encodeURIComponent(value));
|
|
257
|
+
} else if (v.in === 'header') {
|
|
258
|
+
headers[v.name] = value;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (query.length > 0) {
|
|
262
|
+
url += '?' + query.join('&');
|
|
263
|
+
}
|
|
264
|
+
return { url, headers, missing };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A curl command for a request; quoted for a POSIX shell. */
|
|
268
|
+
function curlCommand(method, url, headers, dataFile) {
|
|
269
|
+
const q = (s) => "'" + String(s).split("'").join("'\\''") + "'";
|
|
270
|
+
let cmd = 'curl';
|
|
271
|
+
if (method !== 'GET') {
|
|
272
|
+
cmd += ' -X ' + method;
|
|
273
|
+
}
|
|
274
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
275
|
+
cmd += ' -H ' + q(k + ': ' + v);
|
|
276
|
+
}
|
|
277
|
+
if (dataFile) {
|
|
278
|
+
cmd += ' --data-binary @' + dataFile;
|
|
279
|
+
}
|
|
280
|
+
return cmd + ' ' + q(url);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The page script: wires up every try-it form, and fills in the absolute URLs of the
|
|
284
|
+
// example curl commands.
|
|
285
|
+
function tryItScript() {
|
|
286
|
+
const MAX_SHOW = 200000;
|
|
287
|
+
document.querySelectorAll('form.try-it').forEach(function (form) {
|
|
288
|
+
form.addEventListener('submit', function (e) {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
const values = Array.prototype.map.call(form.querySelectorAll('[data-in]'), function (el) {
|
|
291
|
+
return { in: el.dataset.in, name: el.dataset.name, value: el.value };
|
|
292
|
+
});
|
|
293
|
+
const req = buildTryItRequest(form.dataset.path, values);
|
|
294
|
+
const out = form.querySelector('.try-result');
|
|
295
|
+
const show = function (cls, text) {
|
|
296
|
+
out.querySelector(cls).textContent = text;
|
|
297
|
+
};
|
|
298
|
+
out.hidden = false;
|
|
299
|
+
if (req.missing.length > 0) {
|
|
300
|
+
show('.try-status', 'Required: ' + req.missing.join(', '));
|
|
301
|
+
show('.try-url', '');
|
|
302
|
+
show('.try-curl', '');
|
|
303
|
+
show('.try-body', '');
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const headers = Object.assign({ Accept: form.dataset.accept }, req.headers);
|
|
307
|
+
show('.try-url', 'GET ' + req.url);
|
|
308
|
+
show('.try-curl', curlCommand('GET', location.origin + req.url, headers));
|
|
309
|
+
show('.try-status', 'Sending...');
|
|
310
|
+
show('.try-body', '');
|
|
311
|
+
const started = Date.now();
|
|
312
|
+
fetch(req.url, { headers: headers }).then(function (res) {
|
|
313
|
+
const type = res.headers.get('Content-Type') || '';
|
|
314
|
+
let status = res.status + ' ' + res.statusText + ' (' + (Date.now() - started) + 'ms)';
|
|
315
|
+
status += '\nContent-Type: ' + type;
|
|
316
|
+
['Location', 'Last-Modified', 'Link'].forEach(function (h) {
|
|
317
|
+
if (res.headers.get(h)) {
|
|
318
|
+
status += '\n' + h + ': ' + res.headers.get(h);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
show('.try-status', status);
|
|
322
|
+
if (/json|text|xml|yaml/.test(type)) {
|
|
323
|
+
return res.text().then(function (text) {
|
|
324
|
+
let body = text;
|
|
325
|
+
if (/json/.test(type)) {
|
|
326
|
+
try {
|
|
327
|
+
body = JSON.stringify(JSON.parse(text), null, 2);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
// show it as it came
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (body.length > MAX_SHOW) {
|
|
333
|
+
body = body.substring(0, MAX_SHOW) + '\n... (' + body.length + ' characters; the rest is not shown)';
|
|
334
|
+
}
|
|
335
|
+
show('.try-body', body);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return res.blob().then(function (b) {
|
|
339
|
+
show('.try-body', '(' + b.size + ' bytes of ' + (type || 'unknown content') + ')');
|
|
340
|
+
});
|
|
341
|
+
}).catch(function (err) {
|
|
342
|
+
show('.try-status', 'The request failed: ' + err.message);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
document.querySelectorAll('.try-example').forEach(function (el) {
|
|
347
|
+
el.textContent = curlCommand(el.dataset.method, location.origin + el.dataset.path,
|
|
348
|
+
JSON.parse(el.dataset.headers), el.dataset.file || null);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The content type a try-it request asks for: the success response's first non-HTML one.
|
|
353
|
+
function tryItAccept(op) {
|
|
354
|
+
const ok = Object.keys(op.responses || {}).find(s => /^2/.test(s));
|
|
355
|
+
const types = ok ? Object.keys((op.responses[ok] && op.responses[ok].content) || {}) : [];
|
|
356
|
+
return types.find(t => t !== 'text/html') || 'application/json';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function renderTryIt(spec, method, fullPath, item, op) {
|
|
360
|
+
const params = [...(item.parameters || []), ...(op.parameters || [])].map(p => resolveRef(spec, p));
|
|
361
|
+
if (method !== 'get') {
|
|
362
|
+
// an example only
|
|
363
|
+
const headers = {};
|
|
364
|
+
let file = null;
|
|
365
|
+
const content = op.requestBody && resolveRef(spec, op.requestBody).content;
|
|
366
|
+
if (content) {
|
|
367
|
+
headers['Content-Type'] = Object.keys(content)[0];
|
|
368
|
+
file = 'body.json';
|
|
369
|
+
}
|
|
370
|
+
if ((op.security || []).some(s => Object.keys(s).length > 0)) {
|
|
371
|
+
headers.Authorization = 'Bearer {token}';
|
|
372
|
+
}
|
|
373
|
+
return '<h5>Example</h5><pre class="try-example" data-method="' + method.toUpperCase() +
|
|
374
|
+
'" data-path="' + escape(fullPath) + '" data-headers="' + escape(JSON.stringify(headers)) + '"' +
|
|
375
|
+
(file ? ' data-file="' + file + '"' : '') + '></pre>';
|
|
376
|
+
}
|
|
377
|
+
// closed until wanted: the summary is the button that opens it
|
|
378
|
+
let html = '<details class="try-it-panel"><summary class="btn btn-default btn-sm try-btn">Try it</summary>';
|
|
379
|
+
html += `<form class="try-it" data-path="${escape(fullPath)}" data-accept="${escape(tryItAccept(op))}">`;
|
|
380
|
+
if (params.length > 0) {
|
|
381
|
+
html += '<table class="table table-condensed"><tbody>';
|
|
382
|
+
for (const p of params) {
|
|
383
|
+
const id = `try-${escape(op.operationId || '')}-${escape(p.in)}-${escape(p.name)}`;
|
|
384
|
+
const attrs = `id="${id}" data-in="${escape(p.in)}" data-name="${escape(p.name)}" class="form-control input-sm"`;
|
|
385
|
+
html += `<tr><td style="width: 30%"><label for="${id}"><code>${escape(p.name)}</code>` +
|
|
386
|
+
`${p.required ? ' <span class="label label-default">required</span>' : ''}` +
|
|
387
|
+
`${p.in !== 'query' ? ` <small>(${escape(p.in)})</small>` : ''}</label></td><td>`;
|
|
388
|
+
const schema = resolveRef(spec, p.schema) || {};
|
|
389
|
+
if (Array.isArray(schema.enum)) {
|
|
390
|
+
html += `<select ${attrs}><option value=""></option>` +
|
|
391
|
+
schema.enum.map(v => `<option>${escape(String(v))}</option>`).join('') + '</select>';
|
|
392
|
+
} else {
|
|
393
|
+
const example = p.example !== undefined ? String(p.example) : '';
|
|
394
|
+
// a required path parameter starts with its example, so Send works straight away
|
|
395
|
+
const value = p.in === 'path' && example ? ` value="${escape(example)}"` : '';
|
|
396
|
+
html += `<input type="text" ${attrs}${value} placeholder="${escape(example)}"/>`;
|
|
397
|
+
}
|
|
398
|
+
html += '</td></tr>';
|
|
399
|
+
}
|
|
400
|
+
html += '</tbody></table>';
|
|
401
|
+
}
|
|
402
|
+
html += '<button type="submit" class="btn btn-primary btn-sm">Send</button>';
|
|
403
|
+
html += '<div class="try-result" hidden>' +
|
|
404
|
+
'<pre class="try-url"></pre><pre class="try-curl"></pre>' +
|
|
405
|
+
'<pre class="try-status"></pre><pre class="try-body"></pre></div>';
|
|
406
|
+
return html + '</form></details>';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// The page's own styles. The site's stylesheet is Bootstrap 3, so these use its classes
|
|
410
|
+
// (panel, table-condensed, label, input-sm, btn-default) and fill in the rest.
|
|
411
|
+
const PAGE_STYLE = `<style>
|
|
412
|
+
.openapi-ref td, .openapi-ref th { vertical-align: top; }
|
|
413
|
+
.openapi-ref td code, .openapi-ref td { overflow-wrap: anywhere; }
|
|
414
|
+
.openapi-ref pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
415
|
+
.openapi-ref hr { border-top: 2px solid #ccc; margin: 24px 0; }
|
|
416
|
+
.openapi-ref .openapi-schema { margin-bottom: 24px; }
|
|
417
|
+
.openapi-ref details.pattern { display: inline; }
|
|
418
|
+
.openapi-ref details.pattern > summary { display: inline; cursor: pointer; color: #555; text-decoration: underline dotted; }
|
|
419
|
+
.openapi-ref details.pattern[open] > code { display: block; margin-top: 4px; }
|
|
420
|
+
.openapi-ref summary.try-btn { list-style: none; display: inline-block; margin: 4px 0 8px; font-weight: bold;
|
|
421
|
+
color: #333; background-color: #fff; border: 1px solid #adadad; box-shadow: 0 1px 2px rgba(0,0,0,0.15); }
|
|
422
|
+
.openapi-ref summary.try-btn:hover { background-color: #ebebeb; color: #333; }
|
|
423
|
+
.openapi-ref summary.try-btn::-webkit-details-marker { display: none; }
|
|
424
|
+
.openapi-ref summary.try-btn::before { content: '\\25B6\\00a0'; font-size: 80%; }
|
|
425
|
+
.openapi-ref details[open] > summary.try-btn::before { content: '\\25BC\\00a0'; }
|
|
426
|
+
.openapi-ref details[open] > summary.try-btn { background-color: #e6e6e6; color: #333; box-shadow: inset 0 2px 3px rgba(0,0,0,0.15); }
|
|
427
|
+
.openapi-ref form.try-it { border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; margin-bottom: 8px; background: #fafafa; }
|
|
428
|
+
.openapi-ref .try-result { margin-top: 8px; }
|
|
429
|
+
.openapi-ref .try-body { max-height: 30em; overflow: auto; }
|
|
430
|
+
</style>`;
|
|
431
|
+
|
|
432
|
+
function buildHtml(spec, BASE_PATH) {
|
|
433
|
+
let html = PAGE_STYLE + '<div class="openapi-ref">';
|
|
434
|
+
html += `<h1>${escape(spec.info.title)} API</h1>`;
|
|
435
|
+
html += `<p class="lead">${escape(spec.info.summary || '')}</p>`;
|
|
436
|
+
html += `<p>Machine-readable description (OpenAPI ${escape(spec.openapi)}): ` +
|
|
437
|
+
`<a href="${BASE_PATH}/openapi.json">openapi.json</a> · ` +
|
|
438
|
+
`<a href="${BASE_PATH}/openapi.yaml">openapi.yaml</a></p>`;
|
|
439
|
+
html += markdown(spec.info.description);
|
|
440
|
+
if (spec.externalDocs) {
|
|
441
|
+
html += `<p>See also: <a href="${escape(spec.externalDocs.url)}">${escape(spec.externalDocs.description || spec.externalDocs.url)}</a></p>`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
html += '<h2>Endpoints</h2>';
|
|
445
|
+
// an index, when there are enough of them to need one
|
|
446
|
+
const all = Object.entries(spec.paths).flatMap(([p, item]) => METHODS.filter(m => item[m]).map(m => [p, m, item[m]]));
|
|
447
|
+
if (all.length > 12) {
|
|
448
|
+
html += '<table class="table table-condensed"><tbody>';
|
|
449
|
+
for (const [p, method, op] of all) {
|
|
450
|
+
html += `<tr><td><span class="label label-default">${method.toUpperCase()}</span></td>` +
|
|
451
|
+
`<td><a href="#${escape(op.operationId || '')}"><code>${escape(BASE_PATH + p)}</code></a></td><td>${escape(op.summary || '')}</td></tr>`;
|
|
452
|
+
}
|
|
453
|
+
html += '</tbody></table>';
|
|
454
|
+
}
|
|
455
|
+
let first = true;
|
|
456
|
+
for (const [p, item] of Object.entries(spec.paths)) {
|
|
457
|
+
for (const method of METHODS) {
|
|
458
|
+
const op = item[method];
|
|
459
|
+
if (!op) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (!first) {
|
|
463
|
+
html += '<hr/>';
|
|
464
|
+
}
|
|
465
|
+
first = false;
|
|
466
|
+
html += `<div class="panel panel-default" id="${escape(op.operationId || '')}"><div class="panel-heading">`;
|
|
467
|
+
html += `<span class="label label-primary">${method.toUpperCase()}</span> <code>${escape(BASE_PATH + p)}</code> — ${escape(op.summary || '')}`;
|
|
468
|
+
html += '</div><div class="panel-body">';
|
|
469
|
+
html += markdown(op.description);
|
|
470
|
+
html += renderParameters(spec, [...(item.parameters || []), ...(op.parameters || [])]);
|
|
471
|
+
html += renderResponses(spec, op.responses);
|
|
472
|
+
html += renderTryIt(spec, method, BASE_PATH + p, item, op);
|
|
473
|
+
html += '</div></div>';
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
html += '<hr/><h2>Schemas</h2>';
|
|
478
|
+
for (const [name, schema] of Object.entries((spec.components && spec.components.schemas) || {})) {
|
|
479
|
+
html += renderSchema(name, schema);
|
|
480
|
+
}
|
|
481
|
+
html += '</div>';
|
|
482
|
+
html += '<script>\n' + buildTryItRequest.toString() + '\n' + curlCommand.toString() + '\n(' +
|
|
483
|
+
tryItScript.toString() + ')();\n</script>';
|
|
484
|
+
return html;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* @param {string} specPath - the YAML file
|
|
489
|
+
* @param {string} basePath - where the module is mounted (e.g. '/packages'); used for links
|
|
490
|
+
* and to show full paths in the HTML reference
|
|
491
|
+
* @param {Object} [options]
|
|
492
|
+
* @param {string} [options.schemasPath] - a generated schemas file (see
|
|
493
|
+
* utilities/generate-openapi-schemas.js) whose schemas are merged into components.schemas.
|
|
494
|
+
* A schema in the YAML of the same name wins.
|
|
495
|
+
* @param {Function} [options.build] - adds to the spec once it's loaded (paths made from
|
|
496
|
+
* data, say). The YAML served is then the whole spec, not the file
|
|
497
|
+
* @param {string} [options.serverUrl] - the server url, when the module is mounted in more
|
|
498
|
+
* than one place (the YAML's servers are replaced)
|
|
499
|
+
*/
|
|
500
|
+
function createOpenApiDoc(specPath, basePath, options = {}) {
|
|
501
|
+
let cachedYaml = null;
|
|
502
|
+
let cachedSpec = null;
|
|
503
|
+
let cachedHtml = null;
|
|
504
|
+
let cachedJson = null;
|
|
505
|
+
|
|
506
|
+
function getYaml() {
|
|
507
|
+
if (cachedYaml === null) {
|
|
508
|
+
cachedYaml = options.build || options.serverUrl
|
|
509
|
+
? YAML.stringify(getSpec(), { lineWidth: 0 })
|
|
510
|
+
: fs.readFileSync(specPath, 'utf8');
|
|
511
|
+
}
|
|
512
|
+
return cachedYaml;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// The parsed spec, with info.version set to this server's version. Callers get a copy, so
|
|
516
|
+
// nothing can modify the cached one.
|
|
517
|
+
function getSpec() {
|
|
518
|
+
if (cachedSpec === null) {
|
|
519
|
+
const spec = YAML.parse(fs.readFileSync(specPath, 'utf8'));
|
|
520
|
+
spec.info.version = packageJson.version;
|
|
521
|
+
if (options.serverUrl) {
|
|
522
|
+
spec.servers = [{ url: options.serverUrl }];
|
|
523
|
+
}
|
|
524
|
+
if (options.schemasPath) {
|
|
525
|
+
const generated = JSON.parse(fs.readFileSync(options.schemasPath, 'utf8'));
|
|
526
|
+
spec.components = spec.components || {};
|
|
527
|
+
spec.components.schemas = { ...generated.schemas, ...(spec.components.schemas || {}) };
|
|
528
|
+
}
|
|
529
|
+
if (options.build) {
|
|
530
|
+
options.build(spec);
|
|
531
|
+
}
|
|
532
|
+
cachedSpec = spec;
|
|
533
|
+
}
|
|
534
|
+
return structuredClone(cachedSpec);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// The spec as JSON text, for serving: made once, rather than a copy cloned and serialised
|
|
538
|
+
// for every request
|
|
539
|
+
function getJson() {
|
|
540
|
+
if (cachedJson === null) {
|
|
541
|
+
cachedJson = JSON.stringify(getSpec());
|
|
542
|
+
}
|
|
543
|
+
return cachedJson;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// The body of the HTML reference page (to be wrapped in the module's page template).
|
|
547
|
+
function renderHtml() {
|
|
548
|
+
if (cachedHtml === null) {
|
|
549
|
+
cachedHtml = buildHtml(getSpec(), basePath);
|
|
550
|
+
}
|
|
551
|
+
return cachedHtml;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return { getSpec, getJson, getYaml, renderHtml, SPEC_PATH: specPath, BASE_PATH: basePath };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
module.exports = { createOpenApiDoc, buildTryItRequest, curlCommand, markdown };
|
package/library/utilities.js
CHANGED
|
@@ -60,6 +60,27 @@ const Utilities = {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
return parts.join(' ');
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How long ago a time was, for people: "35 minutes ago", "3 hours ago", "2 days ago"
|
|
67
|
+
* @param {Date|string|number} time - the earlier time
|
|
68
|
+
* @param {number} now - Date.now(), for testing
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
describeAgo(time, now = Date.now()) {
|
|
72
|
+
const secs = Math.max(0, Math.floor((now - new Date(time).getTime()) / 1000));
|
|
73
|
+
const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'} ago`;
|
|
74
|
+
if (secs < 60) {
|
|
75
|
+
return 'just now';
|
|
76
|
+
}
|
|
77
|
+
if (secs < 3600) {
|
|
78
|
+
return plural(Math.floor(secs / 60), 'minute');
|
|
79
|
+
}
|
|
80
|
+
if (secs < 172800) {
|
|
81
|
+
return plural(Math.floor(secs / 3600), 'hour');
|
|
82
|
+
}
|
|
83
|
+
return plural(Math.floor(secs / 86400), 'day');
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fhirsmith",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"txVersion": "1.9.5-SNAPSHOT",
|
|
5
5
|
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
|
|
6
6
|
"main": "server.js",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"cli-progress": "^3.12.0",
|
|
42
42
|
"commander": "^14.0.3",
|
|
43
43
|
"commonmark": "^0.31.2",
|
|
44
|
-
"connect-sqlite3": "^0.9.
|
|
44
|
+
"connect-sqlite3": "^0.9.18",
|
|
45
45
|
"cors": "^2.8.6",
|
|
46
46
|
"express": "^5.2.1",
|
|
47
47
|
"express-rate-limit": "^7.4.1",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"properties-file": "^3.6.4",
|
|
65
65
|
"re2js": "^2.8.0",
|
|
66
66
|
"rimraf": "^5.0.10",
|
|
67
|
-
"sqlite3": "^
|
|
67
|
+
"sqlite3": "^6.0.1",
|
|
68
68
|
"tar": "^7.5.7",
|
|
69
69
|
"winston": "^3.19.0",
|
|
70
70
|
"winston-daily-rotate-file": "^4.7.1",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"@types/jest": "^29.5.8",
|
|
75
75
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
|
76
76
|
"@typescript-eslint/parser": "^8.54.0",
|
|
77
|
+
"ajv": "^8.20.0",
|
|
77
78
|
"eslint": "^8.57.1",
|
|
78
79
|
"eslint-plugin-promise": "^7.2.1",
|
|
79
80
|
"jest": "^29.7.0",
|
|
@@ -111,7 +112,12 @@
|
|
|
111
112
|
"inquirer": {
|
|
112
113
|
"lodash": "4.18.1"
|
|
113
114
|
},
|
|
114
|
-
"brace-expansion": "^5.0.8"
|
|
115
|
+
"brace-expansion": "^5.0.8",
|
|
116
|
+
"csv-parse": "^7.0.3",
|
|
117
|
+
"uuid": "^11.1.1",
|
|
118
|
+
"connect-sqlite3": {
|
|
119
|
+
"sqlite3": "$sqlite3"
|
|
120
|
+
}
|
|
115
121
|
},
|
|
116
122
|
"bin": {
|
|
117
123
|
"tx-import": "./tx/importers/tx-import.js",
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
// The package server's OpenAPI description. openapi.yaml is the source of truth; see the
|
|
8
|
+
// comment at its top.
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { createOpenApiDoc } = require('../library/openapi-doc');
|
|
12
|
+
|
|
13
|
+
module.exports = createOpenApiDoc(path.join(__dirname, 'openapi.yaml'), '/packages');
|