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
package/tx/openapi.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
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 terminology server's OpenAPI description (R5 endpoints only): openapi.yaml (the
|
|
8
|
+
// overview, /metadata, /$versions, the shared components), plus the paths made here from
|
|
9
|
+
// the operations in openapi-operations.js and the read and search interactions, plus the
|
|
10
|
+
// FHIR resource schemas generated into openapi-schemas.json (see openapi-schemas.config.js).
|
|
11
|
+
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { createOpenApiDoc } = require('../library/openapi-doc');
|
|
14
|
+
const { OPERATIONS, COMMON } = require('./openapi-operations');
|
|
15
|
+
|
|
16
|
+
const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
17
|
+
const param = (name) => ({ $ref: `#/components/parameters/${name}` });
|
|
18
|
+
|
|
19
|
+
const RESOURCE_TYPES = ['CodeSystem', 'ValueSet', 'ConceptMap'];
|
|
20
|
+
|
|
21
|
+
const PRIMITIVES = new Set([
|
|
22
|
+
'boolean', 'canonical', 'code', 'date', 'dateTime', 'decimal', 'id', 'instant', 'integer',
|
|
23
|
+
'integer64', 'markdown', 'oid', 'positiveInt', 'string', 'time', 'unsignedInt', 'uri', 'url', 'uuid'
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function isPrimitive(type) {
|
|
27
|
+
return PRIMITIVES.has(type);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The JSON schema of a primitive parameter, as a query parameter. */
|
|
31
|
+
function querySchema(type) {
|
|
32
|
+
switch (type) {
|
|
33
|
+
case 'boolean': return { type: 'boolean' };
|
|
34
|
+
case 'integer': return { type: 'integer' };
|
|
35
|
+
case 'positiveInt': return { type: 'integer', minimum: 1 };
|
|
36
|
+
case 'unsignedInt': return { type: 'integer', minimum: 0 };
|
|
37
|
+
case 'decimal': return { type: 'number' };
|
|
38
|
+
default: return { type: 'string', 'x-fhir-type': type };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// a table cell: backslashes escaped first, then the pipes (which would end the cell)
|
|
43
|
+
function cell(s) {
|
|
44
|
+
return String(s || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function typeCell(p) {
|
|
48
|
+
if (p.type === '(parts)') {
|
|
49
|
+
return 'parts';
|
|
50
|
+
}
|
|
51
|
+
return isPrimitive(p.type) ? `\`${p.type}\`` : p.type;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A markdown table of parameters. */
|
|
55
|
+
function paramTable(params, inbound) {
|
|
56
|
+
const rows = [];
|
|
57
|
+
for (const p of params) {
|
|
58
|
+
let doc = p.doc || '';
|
|
59
|
+
if (inbound && !isPrimitive(p.type)) {
|
|
60
|
+
doc += (doc ? ' ' : '') + '(POST, in a Parameters resource, only)';
|
|
61
|
+
}
|
|
62
|
+
rows.push(`| \`${cell(p.name)}\` | ${typeCell(p)} | ${p.min}..${p.max} | ${cell(doc)} |`);
|
|
63
|
+
for (const part of p.parts || []) {
|
|
64
|
+
rows.push(`| \`${cell(p.name)}.${cell(part.name)}\` | ${typeCell(part)} | ${part.min}..${part.max} | ${cell(part.doc)} |`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return '| Name | Type | Card. | Description |\n|---|---|---|---|\n' + rows.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function camel(s) {
|
|
71
|
+
return s.replace(/-([a-z])/g, (m, c) => c.toUpperCase());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function errorResponses(extra = {}) {
|
|
75
|
+
return {
|
|
76
|
+
'400': { $ref: '#/components/responses/Outcome', description: 'The request is not valid' },
|
|
77
|
+
'404': { $ref: '#/components/responses/Outcome', description: 'Something the request names was not found' },
|
|
78
|
+
'422': { $ref: '#/components/responses/Outcome', description: 'The request could not be processed (e.g. the value set is not valid, or the expansion is too large)' },
|
|
79
|
+
'500': { $ref: '#/components/responses/Outcome', description: 'The server failed' },
|
|
80
|
+
...extra
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function okResponse(op) {
|
|
85
|
+
const schema = ref(op.returns || 'Parameters');
|
|
86
|
+
return {
|
|
87
|
+
description: op.returnsDoc || 'The out parameters (see the table)',
|
|
88
|
+
content: { 'application/fhir+json': { schema }, 'application/json': { schema } }
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The paths of one operation: its levels, and its methods. */
|
|
93
|
+
function queryParameter(qp) {
|
|
94
|
+
const schema = querySchema(qp.type);
|
|
95
|
+
const result = {
|
|
96
|
+
name: qp.name,
|
|
97
|
+
in: 'query',
|
|
98
|
+
description: qp.doc,
|
|
99
|
+
schema: qp.max === '*' ? { type: 'array', items: schema } : schema
|
|
100
|
+
};
|
|
101
|
+
if (qp.min > 0) {
|
|
102
|
+
result.required = true;
|
|
103
|
+
}
|
|
104
|
+
if (qp.max === '*') {
|
|
105
|
+
result.style = 'form';
|
|
106
|
+
result.explode = true;
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formSchema(params) {
|
|
112
|
+
const form = { type: 'object', properties: {} };
|
|
113
|
+
for (const fp of params) {
|
|
114
|
+
form.properties[fp.name] = fp.max === '*' ? { type: 'array', items: querySchema(fp.type) } : querySchema(fp.type);
|
|
115
|
+
}
|
|
116
|
+
return form;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The common parameters: in the overview, and as components the operations refer to. */
|
|
120
|
+
function addCommon(spec) {
|
|
121
|
+
spec.info.description += '\n**Common parameters.** These are read by $expand, $validate-code, ' +
|
|
122
|
+
'$lookup, $subsumes, $translate and $compare, as well as their own parameters:\n\n' + paramTable(COMMON, true) + '\n';
|
|
123
|
+
for (const cp of COMMON.filter(x => isPrimitive(x.type))) {
|
|
124
|
+
spec.components.parameters[`common-${cp.name}`] = queryParameter(cp);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function addOperation(spec, op) {
|
|
129
|
+
const methods = op.methods || ['get', 'post'];
|
|
130
|
+
const queryParams = op.in.filter(p => isPrimitive(p.type));
|
|
131
|
+
const commonQuery = op.common ? COMMON.filter(x => isPrimitive(x.type)) : [];
|
|
132
|
+
let description = (op.description || '') + '\n\n**In parameters**' +
|
|
133
|
+
(op.common ? ' (and the common parameters - see the overview)' : '') + '\n\n' + paramTable(op.in, true);
|
|
134
|
+
if (op.out) {
|
|
135
|
+
description += '\n\n**Out parameters** (a Parameters resource)\n\n' + paramTable(op.out, false);
|
|
136
|
+
}
|
|
137
|
+
for (const level of op.levels) {
|
|
138
|
+
const p = level === 'system' ? `/$${op.name}`
|
|
139
|
+
: level === 'type' ? `/${op.resource}/$${op.name}`
|
|
140
|
+
: `/${op.resource}/{id}/$${op.name}`;
|
|
141
|
+
const item = spec.paths[p] = spec.paths[p] || {};
|
|
142
|
+
const baseId = camel(op.name) + (op.resource || '') + (level === 'instance' ? 'Instance' : '');
|
|
143
|
+
const common = [param('acceptLanguage'), param('cacheId'), param('format')];
|
|
144
|
+
if (level === 'instance') {
|
|
145
|
+
common.unshift(param('id'));
|
|
146
|
+
}
|
|
147
|
+
const levelDoc = level === 'instance' ? `\n\nThe ${op.resource} is the one with this id.` : '';
|
|
148
|
+
for (const method of methods) {
|
|
149
|
+
const o = {
|
|
150
|
+
tags: [op.resource || 'Server'],
|
|
151
|
+
operationId: method === 'get' || methods.length === 1 ? baseId : baseId + 'Post',
|
|
152
|
+
summary: op.summary,
|
|
153
|
+
description: description + levelDoc
|
|
154
|
+
};
|
|
155
|
+
if (method === 'get') {
|
|
156
|
+
o.parameters = [...common, ...queryParams.map(queryParameter), ...commonQuery.map(cp => param(`common-${cp.name}`))];
|
|
157
|
+
} else {
|
|
158
|
+
// parameters that are in the query even for a POST
|
|
159
|
+
o.parameters = [...common, ...op.in.filter(x => x.inQuery).map(qp => ({
|
|
160
|
+
name: qp.name, in: 'query', description: qp.doc, required: qp.min > 0, schema: querySchema(qp.type)
|
|
161
|
+
}))];
|
|
162
|
+
const bodies = op.postBody || ['Parameters'];
|
|
163
|
+
const schema = bodies.length === 1 ? ref(bodies[0]) : { anyOf: bodies.map(ref) };
|
|
164
|
+
const form = formSchema([...queryParams.filter(x => !x.inQuery), ...commonQuery]);
|
|
165
|
+
o.requestBody = {
|
|
166
|
+
required: true,
|
|
167
|
+
description: bodies.length === 1 ? 'The in parameters, as a Parameters resource (or a form)'
|
|
168
|
+
: `The in parameters, as a Parameters resource (or a form), or the ${bodies[1]} itself`,
|
|
169
|
+
content: {
|
|
170
|
+
'application/fhir+json': { schema },
|
|
171
|
+
'application/json': { schema },
|
|
172
|
+
'application/x-www-form-urlencoded': { schema: form }
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
o.responses = { '200': okResponse(op), ...errorResponses(op.responses || {}) };
|
|
177
|
+
item[method] = o;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const SEARCH_DOCS = {
|
|
183
|
+
url: 'The canonical url (exact)',
|
|
184
|
+
system: 'The canonical url (exact; the same as url)',
|
|
185
|
+
version: 'The version (contains)',
|
|
186
|
+
name: 'The name (contains)',
|
|
187
|
+
title: 'The title (contains)',
|
|
188
|
+
status: 'The status (contains)',
|
|
189
|
+
publisher: 'The publisher (contains)',
|
|
190
|
+
description: 'The description (contains)',
|
|
191
|
+
date: 'The date (contains)',
|
|
192
|
+
identifier: 'An identifier (contains)',
|
|
193
|
+
jurisdiction: 'A jurisdiction: a code or display (contains)',
|
|
194
|
+
text: 'The title or description (contains)',
|
|
195
|
+
'content-mode': 'CodeSystem: the content mode (contains)',
|
|
196
|
+
supplements: 'CodeSystem: what it supplements (contains)',
|
|
197
|
+
'source-system': 'ConceptMap: the source code system',
|
|
198
|
+
'target-system': 'ConceptMap: the target code system',
|
|
199
|
+
_offset: 'Paging: where to start (0 based)',
|
|
200
|
+
_count: 'Paging: how many to return (at most 200, or 2000 with _elements)',
|
|
201
|
+
_elements: 'The elements to return (comma separated)',
|
|
202
|
+
_sort: 'The element to sort by (default id)',
|
|
203
|
+
_summary: '`true`, `text`, `data`, `count` or `false`',
|
|
204
|
+
_total: '`none` to leave out the total (default accurate)'
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Read, and search (GET, and POST _search), for each resource type. */
|
|
208
|
+
function addReadSearch(spec) {
|
|
209
|
+
// required here: the search worker requires tx-html, which requires this
|
|
210
|
+
const SEARCH_PARAMS = require('./workers/search').ALLOWED_PARAMS;
|
|
211
|
+
for (const type of RESOURCE_TYPES) {
|
|
212
|
+
const searchParams = SEARCH_PARAMS.filter(n => n !== '_format').map(n => ({
|
|
213
|
+
name: n,
|
|
214
|
+
in: 'query',
|
|
215
|
+
description: SEARCH_DOCS[n] || '',
|
|
216
|
+
schema: ['_offset', '_count'].includes(n) ? { type: 'integer', minimum: 0 } : { type: 'string' }
|
|
217
|
+
}));
|
|
218
|
+
if (type !== 'CodeSystem') {
|
|
219
|
+
searchParams.push({ name: 'source', in: 'query', description: 'Only those from this source (package)', schema: { type: 'string' } });
|
|
220
|
+
}
|
|
221
|
+
const bundle = ref(`${type}SearchBundle`);
|
|
222
|
+
const searchDoc = `Searches the ${type}s the server has. Text matches are case insensitive and partial ` +
|
|
223
|
+
'(contains), except url and system; all the parameters given must match.';
|
|
224
|
+
const searchResponses = {
|
|
225
|
+
'200': { description: `A searchset Bundle of ${type}s`, content: { 'application/fhir+json': { schema: bundle } } },
|
|
226
|
+
'500': { $ref: '#/components/responses/Outcome' }
|
|
227
|
+
};
|
|
228
|
+
spec.paths[`/${type}`] = {
|
|
229
|
+
get: {
|
|
230
|
+
tags: [type], operationId: `search${type}`, summary: `Search ${type}s`, description: searchDoc,
|
|
231
|
+
parameters: [...searchParams, param('format')], responses: searchResponses
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const form = { type: 'object', properties: {} };
|
|
235
|
+
searchParams.forEach(sp => {
|
|
236
|
+
form.properties[sp.name] = sp.schema;
|
|
237
|
+
});
|
|
238
|
+
spec.paths[`/${type}/_search`] = {
|
|
239
|
+
post: {
|
|
240
|
+
tags: [type], operationId: `search${type}Post`, summary: `Search ${type}s (POST)`,
|
|
241
|
+
description: searchDoc + ' The parameters are in a form body.',
|
|
242
|
+
requestBody: { content: { 'application/x-www-form-urlencoded': { schema: form } } },
|
|
243
|
+
responses: searchResponses
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
spec.paths[`/${type}/{id}`] = {
|
|
247
|
+
get: {
|
|
248
|
+
tags: [type], operationId: `read${type}`, summary: `Read a ${type}`,
|
|
249
|
+
parameters: [param('id'), param('format')],
|
|
250
|
+
responses: {
|
|
251
|
+
'200': { description: `The ${type}`, content: { 'application/fhir+json': { schema: ref(type) } } },
|
|
252
|
+
'404': { $ref: '#/components/responses/Outcome', description: 'There is no such resource' }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function build(spec) {
|
|
260
|
+
addCommon(spec);
|
|
261
|
+
addReadSearch(spec);
|
|
262
|
+
for (const op of OPERATIONS) {
|
|
263
|
+
addOperation(spec, op);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const SPEC_PATH = path.join(__dirname, 'openapi.yaml');
|
|
268
|
+
const SCHEMAS_PATH = path.join(__dirname, 'openapi-schemas.json');
|
|
269
|
+
|
|
270
|
+
const docs = new Map();
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The description, for an R5 endpoint (its servers url is the endpoint)
|
|
274
|
+
* @param {string} endpointPath - e.g. /tx/r5
|
|
275
|
+
*/
|
|
276
|
+
function forEndpoint(endpointPath) {
|
|
277
|
+
if (!docs.has(endpointPath)) {
|
|
278
|
+
docs.set(endpointPath, createOpenApiDoc(SPEC_PATH, endpointPath,
|
|
279
|
+
{ schemasPath: SCHEMAS_PATH, build, serverUrl: endpointPath }));
|
|
280
|
+
}
|
|
281
|
+
return docs.get(endpointPath);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Whether an endpoint's FHIR version is the one described (R5) */
|
|
285
|
+
function describes(fhirVersion) {
|
|
286
|
+
return String(fhirVersion).startsWith('5');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = { forEndpoint, describes, isPrimitive, RESOURCE_TYPES, SEARCH_DOCS };
|
package/tx/openapi.yaml
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# OpenAPI description of the FHIRsmith terminology server (an R5 endpoint, e.g. /tx/r5).
|
|
2
|
+
#
|
|
3
|
+
# Three parts, merged when the spec is served (see openapi.js):
|
|
4
|
+
# - this file: the overview, /metadata and /$versions, and the shared components
|
|
5
|
+
# - openapi-operations.js: the terminology operations, as data. openapi.js makes their
|
|
6
|
+
# paths (type and instance level, GET and POST) and their parameter tables, and the
|
|
7
|
+
# read and search paths
|
|
8
|
+
# - openapi-schemas.json: the FHIR R5 resource schemas, generated from the
|
|
9
|
+
# StructureDefinitions by utilities/generate-openapi-schemas.js (see
|
|
10
|
+
# openapi-schemas.config.js)
|
|
11
|
+
#
|
|
12
|
+
# tests/tx/openapi.test.js keeps this honest: every route must be described or explicitly
|
|
13
|
+
# excluded, the parameters must be the ones the code reads, and real responses must
|
|
14
|
+
# validate against the schemas.
|
|
15
|
+
#
|
|
16
|
+
# HTML pages (/, /op.html, /ecl, /problems.html, /info, /library) are not described.
|
|
17
|
+
# info.version is filled in from package.json, and servers from the endpoint, when the spec
|
|
18
|
+
# is served.
|
|
19
|
+
|
|
20
|
+
openapi: 3.1.0
|
|
21
|
+
info:
|
|
22
|
+
title: FHIRsmith Terminology Server
|
|
23
|
+
version: "0.0.0"
|
|
24
|
+
summary: A FHIR terminology server (R5)
|
|
25
|
+
description: |
|
|
26
|
+
A FHIR terminology server: code systems, value sets and concept maps, and the
|
|
27
|
+
terminology operations on them - $expand, $validate-code, $lookup, $subsumes,
|
|
28
|
+
$translate, and more. This describes an R5 endpoint; the server's R4 and R3 endpoints
|
|
29
|
+
work the same way, with the resources of that version.
|
|
30
|
+
|
|
31
|
+
The operations are the ones FHIR defines (see the [terminology
|
|
32
|
+
service](https://hl7.org/fhir/R5/terminology-service.html)), with the behaviour the
|
|
33
|
+
[terminology ecosystem IG](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/) asks of
|
|
34
|
+
the servers in the ecosystem. Each operation lists the parameters this server reads.
|
|
35
|
+
|
|
36
|
+
**Requests.** An operation can be invoked with GET, with its parameters in the query, or
|
|
37
|
+
with POST, with a Parameters resource (`application/fhir+json`), or a form. Parameters
|
|
38
|
+
whose values are not primitives (a Coding, a resource) can only be sent in a Parameters
|
|
39
|
+
resource.
|
|
40
|
+
|
|
41
|
+
**Responses** are JSON by default; `application/fhir+xml` (or `_format=xml`) gets XML,
|
|
42
|
+
and a browser (or `_format=html`) gets an HTML page. Only the JSON is described here.
|
|
43
|
+
Errors are OperationOutcomes, and every issue (except information) has a tx-issue-type
|
|
44
|
+
code in `details.coding` and its message in `details.text`.
|
|
45
|
+
|
|
46
|
+
**Schemas.** The resource schemas describe what this server handles and returns: it
|
|
47
|
+
doesn't reject content outside them (an extension with a Quantity value, say), but it
|
|
48
|
+
isn't designed to do anything with it. They have the JSON `_x` elements (the id and
|
|
49
|
+
extensions of a primitive) and modifierExtension wherever FHIR allows them. Contained
|
|
50
|
+
resources are value sets, in a value set, only.
|
|
51
|
+
|
|
52
|
+
**Caching.** A client that sends the same resources (`tx-resource`) with many requests
|
|
53
|
+
can keep them on the server with `$cache-control`, and then send the cache's id in the
|
|
54
|
+
`X-Cache-Id` header.
|
|
55
|
+
license:
|
|
56
|
+
name: BSD-3-Clause
|
|
57
|
+
url: https://opensource.org/license/bsd-3-clause
|
|
58
|
+
externalDocs:
|
|
59
|
+
description: The FHIR R5 terminology service
|
|
60
|
+
url: https://hl7.org/fhir/R5/terminology-service.html
|
|
61
|
+
servers:
|
|
62
|
+
- url: /tx/r5
|
|
63
|
+
|
|
64
|
+
tags:
|
|
65
|
+
- name: Server
|
|
66
|
+
description: What the server does, and operations on the server as a whole
|
|
67
|
+
- name: CodeSystem
|
|
68
|
+
description: Code systems, and $lookup, $validate-code and $subsumes
|
|
69
|
+
- name: ValueSet
|
|
70
|
+
description: Value sets, and $expand, $validate-code and $compare
|
|
71
|
+
- name: ConceptMap
|
|
72
|
+
description: Concept maps, and $translate
|
|
73
|
+
|
|
74
|
+
paths:
|
|
75
|
+
/metadata:
|
|
76
|
+
get:
|
|
77
|
+
tags: [Server]
|
|
78
|
+
operationId: metadata
|
|
79
|
+
summary: The server's capabilities
|
|
80
|
+
description: |
|
|
81
|
+
The CapabilityStatement, or with `mode=terminology`, the TerminologyCapabilities:
|
|
82
|
+
the code systems (and versions) the server has, and the expansion parameters it
|
|
83
|
+
understands.
|
|
84
|
+
parameters:
|
|
85
|
+
- name: mode
|
|
86
|
+
in: query
|
|
87
|
+
description: '`terminology` for the TerminologyCapabilities'
|
|
88
|
+
schema: { type: string, enum: [terminology] }
|
|
89
|
+
- $ref: '#/components/parameters/format'
|
|
90
|
+
responses:
|
|
91
|
+
'200':
|
|
92
|
+
description: The CapabilityStatement, or the TerminologyCapabilities
|
|
93
|
+
content:
|
|
94
|
+
application/fhir+json:
|
|
95
|
+
schema:
|
|
96
|
+
anyOf:
|
|
97
|
+
- $ref: '#/components/schemas/CapabilityStatement'
|
|
98
|
+
- $ref: '#/components/schemas/TerminologyCapabilities'
|
|
99
|
+
|
|
100
|
+
/$versions:
|
|
101
|
+
get:
|
|
102
|
+
tags: [Server]
|
|
103
|
+
operationId: versions
|
|
104
|
+
summary: The FHIR versions of this endpoint
|
|
105
|
+
description: |
|
|
106
|
+
The FHIR version this endpoint uses. With `Accept: application/fhir+json` the answer
|
|
107
|
+
is a Parameters resource (`version` and `default`); otherwise, a simple JSON object.
|
|
108
|
+
responses:
|
|
109
|
+
'200':
|
|
110
|
+
description: The versions
|
|
111
|
+
content:
|
|
112
|
+
application/fhir+json:
|
|
113
|
+
schema: { $ref: '#/components/schemas/Parameters' }
|
|
114
|
+
application/json:
|
|
115
|
+
schema:
|
|
116
|
+
type: object
|
|
117
|
+
properties:
|
|
118
|
+
versions: { type: array, items: { type: string }, example: ['5.0'] }
|
|
119
|
+
default: { type: string, example: '5.0' }
|
|
120
|
+
required: [versions, default]
|
|
121
|
+
|
|
122
|
+
components:
|
|
123
|
+
parameters:
|
|
124
|
+
id:
|
|
125
|
+
name: id
|
|
126
|
+
in: path
|
|
127
|
+
required: true
|
|
128
|
+
description: The resource's id
|
|
129
|
+
schema: { type: string }
|
|
130
|
+
format:
|
|
131
|
+
name: _format
|
|
132
|
+
in: query
|
|
133
|
+
description: '`json`, `xml` or `html`, instead of the Accept header'
|
|
134
|
+
schema: { type: string, enum: [json, xml, html] }
|
|
135
|
+
acceptLanguage:
|
|
136
|
+
name: Accept-Language
|
|
137
|
+
in: header
|
|
138
|
+
description: The language(s) for displays and messages
|
|
139
|
+
schema: { type: string }
|
|
140
|
+
example: en
|
|
141
|
+
cacheId:
|
|
142
|
+
name: X-Cache-Id
|
|
143
|
+
in: header
|
|
144
|
+
description: A cache of resources, started with $cache-control
|
|
145
|
+
schema: { type: string }
|
|
146
|
+
responses:
|
|
147
|
+
Outcome:
|
|
148
|
+
description: An error, as an OperationOutcome
|
|
149
|
+
content:
|
|
150
|
+
application/fhir+json:
|
|
151
|
+
schema: { $ref: '#/components/schemas/OperationOutcome' }
|
package/tx/operation-context.js
CHANGED
|
@@ -1084,6 +1084,21 @@ class OperationContext {
|
|
|
1084
1084
|
this.contexts.push(vurl);
|
|
1085
1085
|
}
|
|
1086
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* Stop tracking a context, once processing of that value set is finished. The tracked
|
|
1089
|
+
* contexts are the chain of value sets currently being processed, not every value set
|
|
1090
|
+
* seen in the operation: a value set used twice (imported by two others, or twice by
|
|
1091
|
+
* one) is not a circularity - only one that is reached again while it's being
|
|
1092
|
+
* processed is.
|
|
1093
|
+
* @param {string} vurl - the url passed to seeContext
|
|
1094
|
+
*/
|
|
1095
|
+
unseeContext(vurl) {
|
|
1096
|
+
const i = this.contexts.lastIndexOf(vurl);
|
|
1097
|
+
if (i >= 0) {
|
|
1098
|
+
this.contexts.splice(i, 1);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1087
1102
|
/**
|
|
1088
1103
|
* Clear all tracked contexts
|
|
1089
1104
|
*/
|
package/tx/params.js
CHANGED
|
@@ -372,11 +372,6 @@ class TxParameters {
|
|
|
372
372
|
if (claim('no-cache') && asBool()) this.FUid = crypto.randomUUID();
|
|
373
373
|
break;
|
|
374
374
|
}
|
|
375
|
-
case '_incomplete':
|
|
376
|
-
case 'limitedExpansion': {
|
|
377
|
-
if (claim('limitedExpansion')) this.limitedExpansion = asBool();
|
|
378
|
-
break;
|
|
379
|
-
}
|
|
380
375
|
case 'includeDesignations': {
|
|
381
376
|
if (claim('includeDesignations')) this.includeDesignations = asBool();
|
|
382
377
|
break;
|
|
@@ -405,10 +400,6 @@ class TxParameters {
|
|
|
405
400
|
if (claim('default-to-latest-version')) this.defaultToLatestVersion = asBool();
|
|
406
401
|
break;
|
|
407
402
|
}
|
|
408
|
-
case 'incomplete-ok': {
|
|
409
|
-
if (claim('incomplete-ok')) this.incompleteOK = asBool();
|
|
410
|
-
break;
|
|
411
|
-
}
|
|
412
403
|
case 'diagnostics': {
|
|
413
404
|
if (claim('diagnostics')) this.diagnostics = asBool();
|
|
414
405
|
break;
|
|
@@ -629,11 +620,11 @@ class TxParameters {
|
|
|
629
620
|
s = s + '$' + [...this.supplements].sort().join(',') + '|';
|
|
630
621
|
}
|
|
631
622
|
// Further result-affecting parameters that were previously omitted from the
|
|
632
|
-
// key: the text filter (changes which codes expand),
|
|
633
|
-
//
|
|
623
|
+
// key: the text filter (changes which codes expand), whether abstract codes
|
|
624
|
+
// are included, and diagnostics.
|
|
634
625
|
// filter is free text, so JSON.stringify it to avoid delimiter collisions.
|
|
635
626
|
s = s + 'f:' + JSON.stringify(this.filter || '') + '|' +
|
|
636
|
-
b(this.
|
|
627
|
+
b(this.abstractOk) + b(this.diagnostics);
|
|
637
628
|
for (let t of this.FVersionRules) {
|
|
638
629
|
s = s + t.asString() + '|';
|
|
639
630
|
}
|
package/tx/provider.js
CHANGED
|
@@ -172,7 +172,16 @@ class Provider {
|
|
|
172
172
|
|
|
173
173
|
const resources = await contentLoader.getResourcesByType("CodeSystem");
|
|
174
174
|
for (const resource of resources) {
|
|
175
|
-
|
|
175
|
+
let cs;
|
|
176
|
+
try {
|
|
177
|
+
cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion()));
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// one unacceptable resource (e.g. contained resources the server doesn't support)
|
|
180
|
+
// must not stop the rest of the package loading
|
|
181
|
+
require('../library/logger').getInstance().child({ module: 'tx' })
|
|
182
|
+
.warn(`Package ${contentLoader.pid()}: skipping CodeSystem ${resource.id || resource.filename || ''}: ${e.message}`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
176
185
|
cs.sourcePackage = contentLoader.pid();
|
|
177
186
|
this.addCodeSystem(cs);
|
|
178
187
|
}
|
package/tx/tx-html.js
CHANGED
|
@@ -21,6 +21,7 @@ const {OperationOutcomeXML} = require("./xml/operationoutcome-xml");
|
|
|
21
21
|
const {debugLog} = require("./operation-context");
|
|
22
22
|
const {InvalidError} = require("./library/errors");
|
|
23
23
|
const {VALID_FILTER_OPS} = require("./library/renderer");
|
|
24
|
+
const txOpenApi = require("./openapi");
|
|
24
25
|
|
|
25
26
|
const txHtmlLog = Logger.getInstance().child({ module: 'tx-html' });
|
|
26
27
|
|
|
@@ -262,6 +263,11 @@ class TxHtmlRenderer {
|
|
|
262
263
|
? '<a href="' + escape(endpoint.path) + '/library" style="color: gold">Library</a> | '
|
|
263
264
|
: ''
|
|
264
265
|
};
|
|
266
|
+
// the OpenAPI description, on the endpoints it describes (R5)
|
|
267
|
+
if (txOpenApi.describes(endpoint.fhirVersion)) {
|
|
268
|
+
options.apiLink = '<a href="' + escape(endpoint.path) + '/openapi" style="color: gold">API</a> | ';
|
|
269
|
+
options.apiHead = '<link rel="service-desc" type="application/json" href="' + escape(endpoint.path) + '/openapi.json"/>';
|
|
270
|
+
}
|
|
265
271
|
|
|
266
272
|
return htmlServer.renderPage('tx', title, content, options);
|
|
267
273
|
}
|
package/tx/tx.js
CHANGED
|
@@ -54,6 +54,7 @@ const {BundleXML} = require("./xml/bundle-xml");
|
|
|
54
54
|
const ConceptUsageTracker = require("./usage-tracker");
|
|
55
55
|
const ProblemFinder = require("./problems");
|
|
56
56
|
const { buildOperationOutcome, outcomeFromError } = require('./library/operation-outcome');
|
|
57
|
+
const txOpenApi = require('./openapi');
|
|
57
58
|
// const {writeFileSync} = require("fs");
|
|
58
59
|
|
|
59
60
|
class TXModule {
|
|
@@ -521,6 +522,11 @@ class TXModule {
|
|
|
521
522
|
|
|
522
523
|
app.use(express.urlencoded({ extended: true }));
|
|
523
524
|
|
|
525
|
+
// The OpenAPI description, on the endpoints it describes (R5)
|
|
526
|
+
if (txOpenApi.describes(fhirVersion)) {
|
|
527
|
+
this.setupOpenApi(router, endpointInfo);
|
|
528
|
+
}
|
|
529
|
+
|
|
524
530
|
// Set up routes
|
|
525
531
|
this.setupRoutes(router, endpointInfo.path);
|
|
526
532
|
|
|
@@ -540,6 +546,55 @@ class TXModule {
|
|
|
540
546
|
this.log.info(`Endpoint ${endpointPath} registered`);
|
|
541
547
|
}
|
|
542
548
|
|
|
549
|
+
/**
|
|
550
|
+
* The OpenAPI description of an endpoint: /openapi.json, /openapi.yaml, and /openapi (an
|
|
551
|
+
* HTML reference for browsers, the JSON otherwise), and an RFC 8631 Link header on every
|
|
552
|
+
* response that points to them. These are sent directly, not through the res.json the
|
|
553
|
+
* endpoint wraps for FHIR resources.
|
|
554
|
+
*
|
|
555
|
+
* @param {express.Router} router
|
|
556
|
+
* @param {Object} endpointInfo
|
|
557
|
+
*/
|
|
558
|
+
setupOpenApi(router, endpointInfo) {
|
|
559
|
+
const doc = txOpenApi.forEndpoint(endpointInfo.path);
|
|
560
|
+
const sendJson = (res) => res.type('application/json').send(doc.getJson());
|
|
561
|
+
router.use((req, res, next) => {
|
|
562
|
+
res.setHeader('Link', `<${req.baseUrl}/openapi.json>; rel="service-desc", <${req.baseUrl}/openapi>; rel="service-doc"`);
|
|
563
|
+
next();
|
|
564
|
+
});
|
|
565
|
+
const count = (start) => this.countRequest(endpointInfo.path, 'openapi', Date.now() - start);
|
|
566
|
+
router.get('/openapi.json', (req, res) => {
|
|
567
|
+
const start = Date.now();
|
|
568
|
+
try {
|
|
569
|
+
sendJson(res);
|
|
570
|
+
} finally {
|
|
571
|
+
count(start);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
router.get('/openapi.yaml', (req, res) => {
|
|
575
|
+
const start = Date.now();
|
|
576
|
+
try {
|
|
577
|
+
res.type('application/yaml').send(doc.getYaml());
|
|
578
|
+
} finally {
|
|
579
|
+
count(start);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
router.get('/openapi', async (req, res) => {
|
|
583
|
+
const start = Date.now();
|
|
584
|
+
try {
|
|
585
|
+
if (!txHtml.acceptsHtml(req)) {
|
|
586
|
+
return sendJson(res);
|
|
587
|
+
}
|
|
588
|
+
const txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid, this.languages, this.i18n, endpointInfo.path);
|
|
589
|
+
const html = await txhtml.renderPage('API', doc.renderHtml(), endpointInfo, req.txStartTime);
|
|
590
|
+
res.setHeader('Content-Type', 'text/html');
|
|
591
|
+
res.send(html);
|
|
592
|
+
} finally {
|
|
593
|
+
count(start);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
543
598
|
/**
|
|
544
599
|
* Set up routes for an endpoint
|
|
545
600
|
* @param {express.Router} router - Express router
|
package/tx/vs/vs-database.js
CHANGED
|
@@ -743,7 +743,17 @@ class ValueSetDatabase {
|
|
|
743
743
|
const valueSetMap = new Map();
|
|
744
744
|
|
|
745
745
|
for (const row of rows) {
|
|
746
|
-
|
|
746
|
+
let valueSet;
|
|
747
|
+
try {
|
|
748
|
+
valueSet = new ValueSet(JSON.parse(row.content));
|
|
749
|
+
} catch (e) {
|
|
750
|
+
// one unacceptable value set (e.g. one containing a CodeSystem, which the
|
|
751
|
+
// server doesn't support) must not take the whole package's value sets with it
|
|
752
|
+
require('../../library/logger').getInstance().child({ module: 'tx' })
|
|
753
|
+
.warn(`${source || 'ValueSet database'}: skipping ValueSet ${row.url || row.id}${row.version ? '|' + row.version : ''}: ${e.message}`);
|
|
754
|
+
this.vsCount--;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
747
757
|
valueSet.sourcePackage = source;
|
|
748
758
|
// Attach the stored content hash so callers can detect changes
|
|
749
759
|
// without recomputing over the full JSON.
|
|
@@ -839,10 +849,9 @@ class ValueSetDatabase {
|
|
|
839
849
|
});
|
|
840
850
|
} else {
|
|
841
851
|
// Fall back to parsing JSON
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
});
|
|
852
|
+
// a value set the map doesn't have was rejected when the map was loaded
|
|
853
|
+
// (see loadAllValueSets), so it isn't a result
|
|
854
|
+
results = rows.map(row => map.get(row.id)).filter(vs => vs);
|
|
846
855
|
}
|
|
847
856
|
|
|
848
857
|
resolve(results);
|
package/tx/vs/vs-package.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs').promises;
|
|
|
3
3
|
const { AbstractValueSetProvider } = require('./vs-api');
|
|
4
4
|
const { PackageContentLoader } = require('../../library/package-manager');
|
|
5
5
|
const { ValueSetDatabase } = require('./vs-database');
|
|
6
|
+
const { checkContained } = require('../library/canonical-resource');
|
|
6
7
|
const { VersionUtilities } = require('../../library/version-utilities');
|
|
7
8
|
const {validateParameter} = require("../../library/utilities");
|
|
8
9
|
|
|
@@ -126,6 +127,15 @@ class PackageValueSetProvider extends AbstractValueSetProvider {
|
|
|
126
127
|
for (const entry of valueSetEntries) {
|
|
127
128
|
const valueSet = await this.packageLoader.loadFile(entry);
|
|
128
129
|
if (valueSet.url) {
|
|
130
|
+
try {
|
|
131
|
+
// don't store a value set the server can't use (e.g. one that contains a
|
|
132
|
+
// CodeSystem): it would be listed by search, and fail when read
|
|
133
|
+
checkContained(valueSet);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
require('../../library/logger').getInstance().child({ module: 'tx' })
|
|
136
|
+
.warn(`Package ${this.packageLoader.pid ? this.packageLoader.pid() : ''}: skipping ValueSet ${valueSet.url}: ${e.message}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
129
139
|
valueSets.push(valueSet);
|
|
130
140
|
}
|
|
131
141
|
}
|