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.
Files changed (49) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +48 -4
  3. package/config-template.json +70 -1
  4. package/library/cgroup-memory.js +113 -0
  5. package/library/folder-setup.js +8 -1
  6. package/nginx.md +391 -0
  7. package/package.json +3 -2
  8. package/publisher/git-remote.js +141 -0
  9. package/publisher/github-release.js +195 -0
  10. package/publisher/publisher.js +146 -14
  11. package/server.js +51 -14
  12. package/stats.js +19 -13
  13. package/testing/readme.md +112 -0
  14. package/testing/render.js +482 -0
  15. package/testing/search.js +378 -0
  16. package/testing/store.js +266 -0
  17. package/testing/testing-template.html +126 -0
  18. package/testing/testing.js +486 -0
  19. package/translations/Messages.properties +32 -0
  20. package/tx/closure/closure-store.js +396 -0
  21. package/tx/cs/cs-api.js +6 -1
  22. package/tx/cs/cs-rxnorm.js +35 -8
  23. package/tx/cs/cs-snomed.js +142 -27
  24. package/tx/html/codesystem-operations.liquid +177 -0
  25. package/tx/html/ecl-panel.liquid +93 -0
  26. package/tx/html/home-metrics.liquid +39 -76
  27. package/tx/html/tx-template.html +1 -0
  28. package/tx/library/conceptmap.js +22 -6
  29. package/tx/library/designations.js +12 -3
  30. package/tx/library/extensions.js +2 -2
  31. package/tx/library/operation-outcome.js +3 -1
  32. package/tx/library/renderer.js +42 -5
  33. package/tx/operation-context.js +111 -15
  34. package/tx/provider.js +85 -0
  35. package/tx/sct/expressions.js +128 -44
  36. package/tx/sct/text-index.js +419 -0
  37. package/tx/tx-html.js +426 -44
  38. package/tx/tx.js +117 -9
  39. package/tx/workers/cache-control.js +2 -2
  40. package/tx/workers/closure.js +483 -13
  41. package/tx/workers/expand.js +20 -0
  42. package/tx/workers/metadata.js +29 -4
  43. package/tx/workers/read.js +18 -3
  44. package/tx/workers/subsumes.js +18 -2
  45. package/tx/workers/translate.js +167 -20
  46. package/tx/workers/validate.js +61 -182
  47. package/tx/workers/worker.js +9 -0
  48. package/tx/xversion/xv-conceptmap.js +17 -8
  49. package/utilities/submit-test-report.js +150 -0
package/tx/tx.js CHANGED
@@ -49,10 +49,11 @@ const {capabilityStatementFromR5} = require("./xversion/xv-capabiliityStatement"
49
49
  const {bundleFromR5} = require("./xversion/xv-bundle");
50
50
  const {convertResourceToR5} = require("./xversion/xv-resource");
51
51
  const ClosureWorker = require("./workers/closure");
52
+ const { ClosureStore } = require("./closure/closure-store");
52
53
  const {BundleXML} = require("./xml/bundle-xml");
53
54
  const ConceptUsageTracker = require("./usage-tracker");
54
55
  const ProblemFinder = require("./problems");
55
- const { buildOperationOutcome } = require('./library/operation-outcome');
56
+ const { buildOperationOutcome, outcomeFromError } = require('./library/operation-outcome');
56
57
  // const {writeFileSync} = require("fs");
57
58
 
58
59
  class TXModule {
@@ -147,6 +148,13 @@ class TXModule {
147
148
 
148
149
  this.log.info('Initializing TX module');
149
150
 
151
+ // Optional instance code, prefixed onto every cache-id this server issues so a
152
+ // proxy in front of several instances can route by it (see nginx.md). Checked
153
+ // here so a bad value stops startup rather than surfacing per endpoint.
154
+ if (ResourceCache.checkInstanceCode(config.instanceCode)) {
155
+ this.log.info(`Cache-ids will be issued with instance code '${config.instanceCode}'`);
156
+ }
157
+
150
158
  // Load HTML template
151
159
  txHtml.loadTemplate();
152
160
 
@@ -176,8 +184,35 @@ class TXModule {
176
184
  await this.i18n.load();
177
185
  this.log.info('I18n support initialized');
178
186
 
187
+ // $closure keeps its tables on disk, across sessions and restarts, so it only exists
188
+ // when the administrator turns it on and says where they go. If it is turned on and
189
+ // the database can't be opened, that stops startup - silently running without it
190
+ // would be worse.
191
+ this.closureStore = null;
192
+ if (config.closure && config.closure.enabled) {
193
+ this.closureStore = new ClosureStore(config.closure, this.log);
194
+ this.closureStore.open();
195
+ this.log.info(`$closure enabled: tables in ${this.closureStore.path}`);
196
+ const days = config.closure.retentionDays;
197
+ if (days) {
198
+ const prune = async () => {
199
+ try {
200
+ const dropped = await this.closureStore.pruneUnused(days);
201
+ if (dropped.length > 0) {
202
+ this.log.info(`closure: dropped ${dropped.length} table(s) unused for ${days} days`);
203
+ }
204
+ } catch (error) {
205
+ this.log.error(`closure: pruning failed: ${error.message}`);
206
+ }
207
+ };
208
+ void prune(); // errors are logged inside
209
+ this.timers.push(setInterval(prune, 24 * 60 * 60 * 1000));
210
+ }
211
+ }
212
+
179
213
  // Initialize metadata handler with config
180
214
  this.metadataHandler = new MetadataHandler({
215
+ closure: this.closureStore != null,
181
216
  baseUrl: config.baseUrl,
182
217
  serverVersion: packageJson.version,
183
218
  txVersion: packageJson.txVersion,
@@ -259,6 +294,7 @@ class TXModule {
259
294
  // can report it and a client can size its keepalive interval to this server rather
260
295
  // than guessing at a number it has no way to see.
261
296
  endpointInfo.resourceCache.setIdleTimeout(cacheTimeoutMs);
297
+ endpointInfo.resourceCache.setInstanceCode(this.config.instanceCode);
262
298
  if (this.stats) {
263
299
  this.stats.addTask("Client Cache", "5 min");
264
300
  }
@@ -370,9 +406,15 @@ class TXModule {
370
406
  let result;
371
407
 
372
408
  if (isHtml) {
373
- const title = txhtml.buildTitle(data, req);
409
+ // _format=html/fragment: the rendering only, with no page around it, for a
410
+ // page that runs an operation and puts the result inside itself (the ECL
411
+ // panel does this). The rendering is the same either way, so an error comes
412
+ // back as the rendered OperationOutcome rather than as something the caller
413
+ // has to handle separately.
374
414
  const content = await txhtml.render(data, req);
375
- const html = await txhtml.renderPage(title, content, req.txEndpoint, req.txStartTime);
415
+ const fragment = (req.query._format || req.query.format) === 'html/fragment';
416
+ const html = fragment ? content
417
+ : await txhtml.renderPage(txhtml.buildTitle(data, req), content, req.txEndpoint, req.txStartTime);
376
418
  responseSize = Buffer.byteLength(html, 'utf8');
377
419
  res.setHeader('Content-Type', 'text/html');
378
420
  result = res.send(html);
@@ -710,20 +752,21 @@ class TXModule {
710
752
  }
711
753
  });
712
754
 
713
- // ConceptMap/$closure (GET and POST)
714
- router.get('/ConceptMap/\\$closure', async (req, res) => {
755
+ // $closure (GET and POST). System level: the OperationDefinition is
756
+ // ConceptMap-closure, but it is declared system=true, type=false (#100).
757
+ router.get('/\\$closure', async (req, res) => {
715
758
  const start = Date.now();
716
759
  try {
717
- let worker = new ClosureWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
760
+ let worker = new ClosureWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n, this.closureStore, this.config.closure);
718
761
  await worker.handle(req, res, this.log);
719
762
  } finally {
720
763
  this.countRequest(endpointPath, '$closure', Date.now() - start);
721
764
  }
722
765
  });
723
- router.post('/ConceptMap/\\$closure', async (req, res) => {
766
+ router.post('/\\$closure', async (req, res) => {
724
767
  const start = Date.now();
725
768
  try {
726
- let worker = new ClosureWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n);
769
+ let worker = new ClosureWorker(req.txOpContext, this.log, req.txProvider, this.languages, this.i18n, this.closureStore, this.config.closure);
727
770
  await worker.handle(req, res, this.log);
728
771
  } finally {
729
772
  this.countRequest(endpointPath, '$closure', Date.now() - start);
@@ -921,9 +964,15 @@ class TXModule {
921
964
 
922
965
  // Unsupported methods
923
966
  for (const resourceType of resourceTypes) {
924
- router.all(`/${resourceType}/:id`, (req, res) => {
967
+ router.all(`/${resourceType}/:id`, (req, res, next) => {
925
968
  const start = Date.now();
926
969
  try {
970
+ // /ConceptMap/$closure etc: an operation this server doesn't have at this
971
+ // level, not a resource that refuses the method
972
+ if (req.params.id.startsWith('$')) {
973
+ return res.status(404).json(this.operationOutcome('error', 'not-found',
974
+ `Unknown operation on this server: ${req.method} ${req.baseUrl}${req.path}`, 'not-found'));
975
+ }
927
976
  if (['PUT', 'POST', 'DELETE', 'PATCH'].includes(req.method)) {
928
977
  return res.status(405).json(this.operationOutcome(
929
978
  'error',
@@ -931,6 +980,8 @@ class TXModule {
931
980
  `Method ${req.method} is not supported`
932
981
  ));
933
982
  }
983
+ // anything else falls through to the not-found fallback rather than hanging
984
+ return next();
934
985
  } finally {
935
986
  this.countRequest(endpointPath, '$read', Date.now() - start);
936
987
  }
@@ -947,6 +998,22 @@ class TXModule {
947
998
  }
948
999
  });
949
1000
 
1001
+ // The ECL panel: an expression, an edition, and the expansion it produces. ECL is
1002
+ // written by trial and error, so the page keeps the inputs and puts each result under
1003
+ // them rather than navigating away from what you just typed.
1004
+ router.get('/ecl', async (req, res) => {
1005
+ const start = Date.now();
1006
+ try {
1007
+ let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid, this.languages, this.i18n, req.txEndpoint.path);
1008
+ const content = await txhtml.buildEclPage(req);
1009
+ const html = await txhtml.renderPage('ECL', content, req.txEndpoint, req.txStartTime);
1010
+ res.setHeader('Content-Type', 'text/html');
1011
+ res.send(html);
1012
+ } finally {
1013
+ this.countRequest(endpointPath, 'ecl', Date.now() - start);
1014
+ }
1015
+ });
1016
+
950
1017
  router.get('/problems.html', async (req, res) => {
951
1018
  const start = Date.now();
952
1019
  try {
@@ -1066,6 +1133,35 @@ class TXModule {
1066
1133
  };
1067
1134
  router.get('/info/:id', infoHandler);
1068
1135
  router.post('/info/:id', infoHandler);
1136
+
1137
+ // ===== Fallbacks - keep these last =====
1138
+
1139
+ // Nothing above matched. Without this, Express answers with its own HTML
1140
+ // "Cannot POST /r4/$foo" page; a FHIR server must answer with an
1141
+ // OperationOutcome (#100). Goes through the wrapped res.json, so it is
1142
+ // rendered as JSON, XML or HTML to suit the client like everything else.
1143
+ router.use((req, res) => {
1144
+ const start = Date.now();
1145
+ try {
1146
+ const what = req.path.includes('$') ? 'operation' : 'path';
1147
+ res.status(404).json(this.operationOutcome('error', 'not-found',
1148
+ `Unknown ${what} on this server: ${req.method} ${req.baseUrl}${req.path}`, 'not-found'));
1149
+ } finally {
1150
+ this.countRequest(endpointPath, 'unknown', Date.now() - start);
1151
+ }
1152
+ });
1153
+
1154
+ // A handler threw or rejected without answering. Express 5 forwards those here;
1155
+ // its default handler would send an HTML error page.
1156
+ // eslint-disable-next-line no-unused-vars
1157
+ router.use((err, req, res, next) => {
1158
+ this.log.error(err);
1159
+ debugLog(err);
1160
+ if (res.headersSent) {
1161
+ return next(err);
1162
+ }
1163
+ res.status(err.statusCode || 500).json(outcomeFromError(err));
1164
+ });
1069
1165
  }
1070
1166
 
1071
1167
  /**
@@ -1181,6 +1277,10 @@ class TXModule {
1181
1277
  this.timers = [];
1182
1278
  // Clean up any resources if needed
1183
1279
  await this.library.close();
1280
+ if (this.closureStore) {
1281
+ this.closureStore.close();
1282
+ this.closureStore = null;
1283
+ }
1184
1284
  this.log.info('TX module shut down');
1185
1285
  }
1186
1286
 
@@ -1271,6 +1371,14 @@ class TXModule {
1271
1371
  return count;
1272
1372
  }
1273
1373
 
1374
+ // $closure usage for the home page: null when closure isn't turned on
1375
+ closureStats() {
1376
+ if (!this.closureStore) {
1377
+ return null;
1378
+ }
1379
+ return { ...this.closureStore.totals(), retentionDays: this.config.closure.retentionDays || null };
1380
+ }
1381
+
1274
1382
  // High-water marks for the client cache, summed across endpoints.
1275
1383
  maxClientCacheCount() {
1276
1384
  let count = 0;
@@ -13,7 +13,6 @@
13
13
  // unknown/expired cache later instead of failing obscurely deep inside a validation.
14
14
  //
15
15
 
16
- const crypto = require('crypto');
17
16
  const { TerminologyWorker, CACHE_ID_HEADER } = require('./worker');
18
17
  const { Parameters } = require('../library/parameters');
19
18
  const { Issue, buildOperationOutcome, outcomeFromError } = require('../library/operation-outcome');
@@ -135,7 +134,8 @@ class CacheControlWorker extends TerminologyWorker {
135
134
  // Flip this to default-true once all clients send an explicit value.
136
135
  const sealed = this.readSealed(params.jsonObj);
137
136
 
138
- const cacheId = crypto.randomUUID();
137
+ // "<instanceCode>.<uuid>" when this server has an instance code, else a bare UUID
138
+ const cacheId = cache.newCacheId();
139
139
  cache.set(cacheId, resources, sealed);
140
140
 
141
141
  return res.status(200).json({