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.
Files changed (62) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +3 -2
  3. package/config-template.json +9 -0
  4. package/library/fhir-openapi-schema.js +441 -0
  5. package/library/html-server.js +9 -3
  6. package/library/npm-audit.js +265 -0
  7. package/library/openapi-doc.js +557 -0
  8. package/library/utilities.js +21 -0
  9. package/package.json +10 -4
  10. package/packages/openapi.js +13 -0
  11. package/packages/openapi.yaml +603 -0
  12. package/packages/packages-template.html +4 -2
  13. package/packages/packages.js +286 -81
  14. package/packages/readme.md +120 -0
  15. package/registry/api.js +117 -41
  16. package/registry/crawler.js +24 -0
  17. package/registry/fhirsmith-releases.js +170 -0
  18. package/registry/model.js +10 -0
  19. package/registry/openapi.js +13 -0
  20. package/registry/openapi.yaml +311 -0
  21. package/registry/readme.md +107 -208
  22. package/registry/registry-template.html +4 -1
  23. package/registry/registry.js +180 -19
  24. package/server.js +19 -5
  25. package/testing/openapi-schemas.config.js +58 -0
  26. package/testing/openapi-schemas.json +1920 -0
  27. package/testing/openapi.js +15 -0
  28. package/testing/openapi.yaml +273 -0
  29. package/testing/readme.md +43 -1
  30. package/testing/search.js +1 -1
  31. package/testing/testing-template.html +2 -0
  32. package/testing/testing.js +24 -0
  33. package/translations/Messages.properties +1 -0
  34. package/tx/README.md +58 -0
  35. package/tx/cm/cm-database.js +10 -1
  36. package/tx/cm/cm-package.js +8 -1
  37. package/tx/cs/cs-rxnorm.js +23 -6
  38. package/tx/html/tx-template.html +2 -0
  39. package/tx/library/canonical-resource.js +50 -1
  40. package/tx/library/codesystem.js +1 -0
  41. package/tx/library/conceptmap.js +1 -0
  42. package/tx/library/valueset.js +16 -0
  43. package/tx/library.js +17 -2
  44. package/tx/openapi-operations.js +363 -0
  45. package/tx/openapi-schemas.config.js +118 -0
  46. package/tx/openapi-schemas.json +7903 -0
  47. package/tx/openapi.js +289 -0
  48. package/tx/openapi.yaml +151 -0
  49. package/tx/operation-context.js +15 -0
  50. package/tx/params.js +3 -12
  51. package/tx/provider.js +10 -1
  52. package/tx/tx-html.js +6 -0
  53. package/tx/tx.js +55 -0
  54. package/tx/vs/vs-database.js +14 -5
  55. package/tx/vs/vs-package.js +10 -0
  56. package/tx/workers/expand.js +35 -16
  57. package/tx/workers/metadata.js +0 -13
  58. package/tx/workers/validate.js +65 -19
  59. package/tx/workers/worker.js +29 -8
  60. package/utilities/extract-schema-json.js +167 -0
  61. package/utilities/generate-openapi-schemas.js +49 -0
  62. package/xig/xig.js +2 -4
@@ -9,6 +9,15 @@ const Logger = require('../library/logger');
9
9
  const regLog = Logger.getInstance().child({ module: 'registry' });
10
10
  const folders = require('../library/folder-setup');
11
11
  const escape = require('escape-html');
12
+ const registryOpenApi = require('./openapi');
13
+ const { FhirsmithReleases, isFhirsmith, describeAge } = require('./fhirsmith-releases');
14
+
15
+ // The query parameters of the public API. These are the contract published in openapi.yaml,
16
+ // and tests/registry/openapi.test.js checks that the two agree - so change both together.
17
+ // Parameters are read leniently (a repeated parameter takes its first value, and unknown
18
+ // parameters are ignored) because existing ecosystem clients rely on that.
19
+ const DISCOVERY_PARAMS = ['registry', 'server', 'fhirVersion', 'url', 'authoritativeOnly', 'language'];
20
+ const RESOLVE_PARAMS = ['fhirVersion', 'url', 'version', 'valueSet', 'authoritativeOnly', 'language', 'usage'];
12
21
 
13
22
  class RegistryModule {
14
23
  constructor(stats) {
@@ -50,6 +59,13 @@ class RegistryModule {
50
59
  // Initialize API with crawler
51
60
  this.api = new RegistryAPI(this.crawler);
52
61
 
62
+ // FHIRsmith release dates, for the software page. CHANGELOG.md until GitHub answers
63
+ this.releases = new FhirsmithReleases(crawlerConfig, this.logger);
64
+ this.releases.loadFromChangelog();
65
+ if (config.releasesUrl !== '') {
66
+ this.releases.refresh().catch(() => {});
67
+ }
68
+
53
69
  // Load saved data if available
54
70
  await this.loadSavedData();
55
71
 
@@ -145,6 +161,9 @@ class RegistryModule {
145
161
  try {
146
162
  // Perform the crawl
147
163
  const newData = await this.crawler.crawl(this.config.masterUrl);
164
+ if (this.releases && this.config.releasesUrl !== '') {
165
+ await this.releases.refresh();
166
+ }
148
167
 
149
168
  // Thread-safe update of current data
150
169
  await this.updateData(() => {
@@ -210,6 +229,9 @@ class RegistryModule {
210
229
  // Content Security Policy
211
230
  res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'");
212
231
 
232
+ // RFC 8631: where to find the machine-readable description of this API
233
+ res.setHeader('Link', `<${req.baseUrl}/openapi.json>; rel="service-desc", <${req.baseUrl}/openapi>; rel="service-doc"`);
234
+
213
235
  next();
214
236
  });
215
237
  }
@@ -230,6 +252,36 @@ class RegistryModule {
230
252
  this.router.get('/', this.handleMainPage.bind(this));
231
253
  this.router.get('/resolve', this.handleResolveEndpoint.bind(this));
232
254
  this.router.get('/log', this.handleLogEndpoint.bind(this));
255
+ this.router.get('/software', this.handleSoftwarePage.bind(this));
256
+
257
+ // OpenAPI description of this API: /openapi.json, /openapi.yaml, and /openapi (an HTML
258
+ // reference for browsers, the JSON otherwise)
259
+ this.router.get('/openapi.json', (req, res) => {
260
+ res.type('application/json').send(registryOpenApi.getJson());
261
+ });
262
+ this.router.get('/openapi.yaml', (req, res) => {
263
+ res.setHeader('Content-Type', 'application/yaml');
264
+ res.send(registryOpenApi.getYaml());
265
+ });
266
+ this.router.get('/openapi', (req, res) => {
267
+ const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
268
+ if (!acceptsHtml) {
269
+ res.type('application/json').send(registryOpenApi.getJson());
270
+ return;
271
+ }
272
+ try {
273
+ if (!htmlServer.hasTemplate('registry')) {
274
+ htmlServer.loadTemplate('registry', path.join(__dirname, 'registry-template.html'));
275
+ }
276
+ const html = htmlServer.renderPage('registry', 'Terminology Server Registry API',
277
+ registryOpenApi.renderHtml(), this.api ? this.api.getStatistics() : {});
278
+ res.setHeader('Content-Type', 'text/html');
279
+ res.send(html);
280
+ } catch (error) {
281
+ this.logger.error('Error rendering OpenAPI page:', error);
282
+ res.status(500).send(`<html><body><h1>Error</h1><p>${escape(error.message)}</p></body></html>`);
283
+ }
284
+ });
233
285
  }
234
286
 
235
287
  /**
@@ -357,21 +409,23 @@ class RegistryModule {
357
409
  const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html');
358
410
 
359
411
  if (!acceptsHtml) {
360
- // Return JSON overview
361
- return res.json({
362
- name: 'FHIR Terminology Server Registry',
363
- description: 'Registry and discovery service for FHIR terminology servers',
364
- endpoints: {
365
- status: '/registry/api/status',
366
- statistics: '/registry/api/stats',
367
- registries: '/registry/api/registries',
368
- queryCodeSystem: '/registry/api/query/codesystem',
369
- queryValueSet: '/registry/api/query/valueset',
370
- bestServer: '/registry/api/best-server/{type}',
371
- errors: '/registry/api/errors'
372
- },
373
- documentation: 'https://github.com/your-org/fhir-registry'
374
- });
412
+ // The ecosystem Discovery API
413
+ const params = this._normalizeQueryParams(req.query);
414
+ if (params.url && !this._isValidUrl(params.url.split('|')[0])) {
415
+ return res.status(400).json({error: 'Invalid code system URL format'});
416
+ }
417
+ try {
418
+ const filters = {};
419
+ for (const name of DISCOVERY_PARAMS) {
420
+ if (params[name]) {
421
+ filters[name] = params[name];
422
+ }
423
+ }
424
+ return res.json(this.api.discover(filters));
425
+ } catch (error) {
426
+ this.logger.error('Error in discovery:', error);
427
+ return res.status(500).json({error: error.message});
428
+ }
375
429
  }
376
430
 
377
431
  // Render HTML page
@@ -416,6 +470,114 @@ class RegistryModule {
416
470
  }
417
471
  }
418
472
 
473
+ /**
474
+ * The software page - what each registered server is running, and for FHIRsmith,
475
+ * how old that release is
476
+ */
477
+ async handleSoftwarePage(req, res) {
478
+ const start = Date.now();
479
+ try {
480
+ if (!htmlServer.hasTemplate('registry')) {
481
+ htmlServer.loadTemplate('registry', path.join(__dirname, 'registry-template.html'));
482
+ }
483
+ const startTime = Date.now();
484
+ const content = this.buildSoftwareContent();
485
+ const stats = this.api.getStatistics();
486
+ stats.processingTime = Date.now() - startTime;
487
+ const html = htmlServer.renderPage('registry', 'Terminology Server Software', content, stats);
488
+ res.setHeader('Content-Type', 'text/html');
489
+ res.send(html);
490
+ } catch (error) {
491
+ this.logger.error('Error rendering software page:', error);
492
+ res.status(500).send(`<html><body><h1>Error</h1><p>${escape(error.message)}</p></body></html>`);
493
+ } finally {
494
+ this.stats.countRequest('software', Date.now() - start);
495
+ }
496
+ }
497
+
498
+ buildSoftwareContent(now = Date.now()) {
499
+ const data = this.api.getData();
500
+ if (!data || !data.registries) {
501
+ return '<div class="alert alert-info"><h4>Registry data not yet available</h4>' +
502
+ '<p>The initial crawl is in progress. Please refresh in a moment.</p></div>';
503
+ }
504
+
505
+ const rows = [];
506
+ for (const registry of data.registries) {
507
+ for (const server of registry.servers || []) {
508
+ for (const version of server.versions || []) {
509
+ rows.push({
510
+ name: server.name || server.code || '',
511
+ url: version.address || '',
512
+ fhirVersion: version.version || '',
513
+ software: version.software && version.software !== 'unknown' ? version.software : '',
514
+ softwareVersion: version.softwareVersion || '',
515
+ error: version.error,
516
+ lastSuccess: version.lastSuccess
517
+ });
518
+ }
519
+ }
520
+ }
521
+ rows.sort((a, b) => a.name.localeCompare(b.name) || a.url.localeCompare(b.url));
522
+
523
+ let html = '<h3>Terminology Server Software</h3>';
524
+ const latest = this.releases ? this.releases.latest() : null;
525
+ if (latest) {
526
+ html += `<p>The current FHIRsmith release is <b>v${escape(latest.version)}</b>, released ` +
527
+ `${escape(latest.date.toISOString().substring(0, 10))}. ` +
528
+ 'Servers running older FHIRsmith releases are missing security fixes, both in FHIRsmith ' +
529
+ 'and in the libraries it depends on, and should be upgraded.</p>';
530
+ }
531
+
532
+ html += '<table class="grid">';
533
+ html += '<thead><tr><th>Server</th><th>URL</th><th>FHIR</th><th>Software</th><th>Version</th>' +
534
+ '<th>Released</th><th>Age</th></tr></thead><tbody>';
535
+ for (const row of rows) {
536
+ html += '<tr>';
537
+ html += `<td>${escape(row.name)}</td>`;
538
+ html += `<td><a href="${escape(row.url)}" target="_blank" rel="noopener">${escape(row.url)}</a>`;
539
+ if (row.error) {
540
+ html += ` <span class="text-danger" title="${escape(row.error)}">(unreachable)</span>`;
541
+ }
542
+ html += '</td>';
543
+ html += `<td>${escape(row.fhirVersion)}</td>`;
544
+ html += `<td>${row.software ? escape(row.software.replace('Reference Server', 'HealthIntersections')) : '<i>unknown</i>'}</td>`;
545
+ html += `<td>${row.softwareVersion ? escape(row.softwareVersion) : '<i>unknown</i>'}</td>`;
546
+ html += this._renderReleaseCells(row, now);
547
+ html += '</tr>';
548
+ }
549
+ html += '</tbody></table>';
550
+
551
+ if (this.releases && this.releases.source !== 'none') {
552
+ html += `<p class="text-muted"><small>FHIRsmith release dates from ${escape(this.releases.source)}.</small></p>`;
553
+ }
554
+ return html;
555
+ }
556
+
557
+ _renderReleaseCells(row, now) {
558
+ if (!isFhirsmith(row.software) || !this.releases) {
559
+ return '<td></td><td></td>';
560
+ }
561
+ const d = this.releases.describe(row.softwareVersion, now);
562
+ const behind = d.behind ? ` (${d.behind} release${d.behind === 1 ? '' : 's'} behind)` : '';
563
+ switch (d.status) {
564
+ case 'current':
565
+ return `<td>${d.release.date.toISOString().substring(0, 10)}</td>` +
566
+ `<td><span class="text-success">${describeAge(d.ageDays)} - current release</span></td>`;
567
+ case 'outdated': {
568
+ // more than 3 months, or more than 3 releases, out of date is flagged
569
+ const cls = d.ageDays > 90 || d.behind > 3 ? 'text-danger' : 'text-warning';
570
+ return `<td>${d.release.date.toISOString().substring(0, 10)}</td>` +
571
+ `<td><span class="${cls}">${describeAge(d.ageDays)}${behind}</span></td>`;
572
+ }
573
+ case 'dev':
574
+ return '<td></td><td>development build' +
575
+ (d.release ? ` after v${escape(d.release.version)}` : '') + behind + '</td>';
576
+ default:
577
+ return '<td></td><td><i>unknown</i></td>';
578
+ }
579
+ }
580
+
419
581
  /**
420
582
  * Build HTML content for main page
421
583
  */
@@ -1086,10 +1248,6 @@ class RegistryModule {
1086
1248
  this.logger.info(`Resolved CodeSystem ${url} for FHIR ${fhirVersion} (usage=${usage}, language=${language || 'none'}): ${matches}`);
1087
1249
  }
1088
1250
 
1089
- // If only authoritative servers are requested, filter results
1090
- if (authoritativeOnly === 'true' && result) {
1091
- result.candidates = [];
1092
- }
1093
1251
  if (acceptsHtml) {
1094
1252
  try {
1095
1253
  const startTime = Date.now();
@@ -1456,4 +1614,7 @@ class RegistryModule {
1456
1614
  }
1457
1615
  }
1458
1616
 
1617
+ RegistryModule.DISCOVERY_PARAMS = DISCOVERY_PARAMS;
1618
+ RegistryModule.RESOLVE_PARAMS = RESOLVE_PARAMS;
1619
+
1459
1620
  module.exports = RegistryModule;
package/server.js CHANGED
@@ -17,6 +17,7 @@ const { statSync, readdirSync } = require('fs');
17
17
  const escape = require('escape-html');
18
18
  const { resolveWithin } = require('./library/path-safety');
19
19
  const { readCgroupMemoryLimit } = require('./library/cgroup-memory');
20
+ const { NpmAudit } = require('./library/npm-audit');
20
21
 
21
22
  // Load configuration BEFORE logger
22
23
  let config;
@@ -110,10 +111,13 @@ app.use(cors(config.server.cors));
110
111
  const modules = {};
111
112
 
112
113
  let stats = null;
114
+ // periodic npm advisory check of the installed packages - see library/npm-audit.js
115
+ let npmAudit = null;
113
116
 
114
117
  // Initialize modules based on configuration
115
118
  async function initializeModules() {
116
119
  stats = new ServerStats(config.stats, serverLog);
120
+ npmAudit = new NpmAudit(config.npmAudit || {}, Logger.getInstance().child({ module: 'npm-audit' }), __dirname, stats);
117
121
 
118
122
  // Initialize SHL module
119
123
  if (config.modules?.shl?.enabled) {
@@ -305,7 +309,8 @@ async function loadTemplates() {
305
309
 
306
310
  async function buildRootPageContent() {
307
311
  stats.requestCount++;
308
- let content = '<div class="row mb-4">';
312
+ let content = npmAudit ? npmAudit.renderBanner() : '';
313
+ content += '<div class="row mb-4">';
309
314
  content += '<div class="col-12">';
310
315
 
311
316
  content += '<h3>Available Modules</h3>';
@@ -314,7 +319,7 @@ async function buildRootPageContent() {
314
319
  // Check which modules are enabled and add them to the list
315
320
  if (config.modules.packages.enabled) {
316
321
  content += '<li class="list-group-item">';
317
- content += '<a href="/packages" class="text-decoration-none">Package Server</a>: Browse and download FHIR Implementation Guide packages';
322
+ content += '<a href="/packages" class="text-decoration-none">Package Server</a>: Browse and download FHIR Implementation Guide packages (<a href="/packages/openapi">API</a>)';
318
323
  content += '</li>';
319
324
  }
320
325
 
@@ -339,7 +344,7 @@ async function buildRootPageContent() {
339
344
  if (config.modules.registry && config.modules.registry.enabled) {
340
345
  content += '<li class="list-group-item">';
341
346
  content += '<a href="/tx-reg" class="text-decoration-none">Terminology Server Registry</a>: ';
342
- content += 'Discover and query FHIR terminology servers for code system and value set support';
347
+ content += 'Discover and query FHIR terminology servers for code system and value set support (<a href="/tx-reg/openapi">API</a>)';
343
348
  content += '</li>';
344
349
  }
345
350
 
@@ -374,7 +379,7 @@ async function buildRootPageContent() {
374
379
  if (config.modules?.testing?.enabled) {
375
380
  content += '<li class="list-group-item">';
376
381
  content += '<a href="/testing" class="text-decoration-none">Test Reports</a>: ';
377
- content += 'TestReports submitted by TxTester and other test tools';
382
+ content += 'TestReports submitted by TxTester and other test tools (<a href="/testing/openapi">API</a>)';
378
383
  content += '</li>';
379
384
  }
380
385
 
@@ -402,7 +407,9 @@ async function buildRootPageContent() {
402
407
  if (config.modules.tx.endpoints && config.modules.tx.endpoints.length > 0) {
403
408
  content += '<ul class="mt-2 mb-0">';
404
409
  for (const endpoint of config.modules.tx.endpoints) {
405
- content += `<li><a href="${endpoint.path}" class="text-decoration-none">${endpoint.path}</a> (FHIR v${endpoint.fhirVersion}${endpoint.context ? ', context: ' + endpoint.context : ''})</li>`;
410
+ // the OpenAPI description is for the R5 endpoints
411
+ const api = String(endpoint.fhirVersion).startsWith('5') ? ` (<a href="${endpoint.path}/openapi">API</a>)` : '';
412
+ content += `<li><a href="${endpoint.path}" class="text-decoration-none">${endpoint.path}</a> (FHIR v${endpoint.fhirVersion}${endpoint.context ? ', context: ' + endpoint.context : ''})${api}</li>`;
406
413
  }
407
414
  content += '</ul>';
408
415
  }
@@ -658,6 +665,9 @@ app.get('/dashboard', async (req, res) => {
658
665
  startTime: stats.startTime
659
666
  });
660
667
  content += stats.taskDetails();
668
+ if (npmAudit) {
669
+ content += npmAudit.renderDashboard();
670
+ }
661
671
  content += `<p>Data: ${folders.dataDir()}</p>`;
662
672
 
663
673
  content = '<div class="row mb-4"><div class="col-12">' + content + '</div></div>';
@@ -790,6 +800,7 @@ async function startServer() {
790
800
  stats.markStarted();
791
801
  serverLog.info(`=== Server running on http://localhost:${PORT} ===`);
792
802
  });
803
+ npmAudit.start();
793
804
  if (modules.packages && config.modules.packages.enabled) {
794
805
  modules.packages.startInitialCrawler();
795
806
  }
@@ -817,6 +828,9 @@ async function startServer() {
817
828
  // Graceful shutdown
818
829
  process.on('SIGINT', async () => {
819
830
  serverLog.info('\nShutting down server...');
831
+ if (npmAudit) {
832
+ npmAudit.stop();
833
+ }
820
834
 
821
835
  // Shutdown all modules
822
836
  for (const [moduleName, moduleInstance] of Object.entries(modules)) {
@@ -0,0 +1,58 @@
1
+ // What utilities/generate-openapi-schemas.js generates for the testing module's OpenAPI
2
+ // description (into openapi-schemas.json, which the loader merges into openapi.yaml's
3
+ // components). Regenerate after changing this:
4
+ //
5
+ // node utilities/generate-openapi-schemas.js testing
6
+ //
7
+ // tests/testing/openapi.test.js fails if the generated file is out of date, and checks that
8
+ // the overlay requires everything validateReport() in testing.js requires.
9
+
10
+ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
11
+
12
+ module.exports = {
13
+ package: 'hl7.fhir.r5.core#5.0.0',
14
+ roots: ['TestReport', 'OperationOutcome', 'Bundle'],
15
+
16
+ // reports may not contain resources (validateReport rejects them too)
17
+ prohibit: ['contained'],
18
+
19
+ overlay: {
20
+ TestReport: {
21
+ description: 'A TestReport, as this server accepts it: an R5 TestReport (or an R4 one - see testScript), ' +
22
+ 'with name, tester, issued and at least one participant, and no contained resources.',
23
+ required: ['name', 'tester', 'issued', 'participant'],
24
+ properties: {
25
+ participant: { minItems: 1 },
26
+ testScript: {
27
+ $replace: true,
28
+ description: 'The TestScript that was run: a canonical (R5), or a Reference (R4)',
29
+ anyOf: [{ type: 'string' }, ref('Reference')]
30
+ }
31
+ }
32
+ },
33
+
34
+ // the search response: a Bundle of TestReports, plus an OperationOutcome entry
35
+ // (search.mode = outcome) when unknown parameters were ignored
36
+ TestReportSearchBundle: {
37
+ description: 'A searchset Bundle of TestReports',
38
+ allOf: [
39
+ ref('Bundle'),
40
+ {
41
+ type: 'object',
42
+ properties: {
43
+ type: { const: 'searchset' },
44
+ entry: {
45
+ type: 'array',
46
+ items: {
47
+ type: 'object',
48
+ properties: {
49
+ resource: { anyOf: [ref('TestReport'), ref('OperationOutcome')] }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ ]
56
+ }
57
+ }
58
+ };