fhirsmith 0.14.0 → 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 (73) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +3 -2
  3. package/config-template.json +20 -0
  4. package/library/fhir-openapi-schema.js +441 -0
  5. package/library/html-server.js +9 -3
  6. package/library/languages.js +15 -0
  7. package/library/npm-audit.js +265 -0
  8. package/library/openapi-doc.js +557 -0
  9. package/library/same-origin.js +62 -0
  10. package/library/utilities.js +21 -0
  11. package/nginx.md +17 -9
  12. package/package.json +10 -4
  13. package/packages/openapi.js +13 -0
  14. package/packages/openapi.yaml +603 -0
  15. package/packages/packages-template.html +4 -2
  16. package/packages/packages.js +286 -81
  17. package/packages/readme.md +120 -0
  18. package/publisher/publisher-template.html +1 -0
  19. package/publisher/publisher.js +95 -42
  20. package/registry/api.js +117 -41
  21. package/registry/crawler.js +24 -0
  22. package/registry/fhirsmith-releases.js +170 -0
  23. package/registry/model.js +10 -0
  24. package/registry/openapi.js +13 -0
  25. package/registry/openapi.yaml +311 -0
  26. package/registry/readme.md +107 -208
  27. package/registry/registry-template.html +4 -1
  28. package/registry/registry.js +183 -20
  29. package/server.js +25 -6
  30. package/testing/openapi-schemas.config.js +58 -0
  31. package/testing/openapi-schemas.json +1920 -0
  32. package/testing/openapi.js +15 -0
  33. package/testing/openapi.yaml +273 -0
  34. package/testing/readme.md +75 -1
  35. package/testing/render.js +270 -29
  36. package/testing/search.js +1 -1
  37. package/testing/store.js +152 -8
  38. package/testing/testing-template.html +2 -0
  39. package/testing/testing.js +307 -9
  40. package/translations/Messages.properties +1 -0
  41. package/tx/README.md +58 -0
  42. package/tx/cm/cm-database.js +10 -1
  43. package/tx/cm/cm-package.js +8 -1
  44. package/tx/cs/cs-rxnorm.js +23 -6
  45. package/tx/cs/cs-snomed.js +3 -72
  46. package/tx/html/tx-template.html +2 -0
  47. package/tx/library/canonical-resource.js +50 -1
  48. package/tx/library/codesystem.js +1 -0
  49. package/tx/library/conceptmap.js +1 -0
  50. package/tx/library/valueset.js +16 -0
  51. package/tx/library.js +17 -2
  52. package/tx/openapi-operations.js +363 -0
  53. package/tx/openapi-schemas.config.js +118 -0
  54. package/tx/openapi-schemas.json +7903 -0
  55. package/tx/openapi.js +289 -0
  56. package/tx/openapi.yaml +151 -0
  57. package/tx/operation-context.js +15 -0
  58. package/tx/params.js +3 -12
  59. package/tx/provider.js +10 -1
  60. package/tx/sct/editions.js +70 -0
  61. package/tx/tx-html.js +6 -0
  62. package/tx/tx.js +55 -0
  63. package/tx/vs/vs-database.js +14 -5
  64. package/tx/vs/vs-package.js +10 -0
  65. package/tx/workers/expand.js +37 -18
  66. package/tx/workers/metadata.js +0 -13
  67. package/tx/workers/translate.js +25 -1
  68. package/tx/workers/validate.js +74 -24
  69. package/tx/workers/worker.js +32 -43
  70. package/utilities/extract-schema-json.js +167 -0
  71. package/utilities/generate-openapi-schemas.js +49 -0
  72. package/utilities/submit-test-report.js +1 -1
  73. 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
  */
@@ -721,7 +883,8 @@ class RegistryModule {
721
883
  * @returns {string} Formatted URL with edition description
722
884
  */
723
885
  _describeSnomedEdition(url) {
724
- if (!url.startsWith('http://snomed.info/sct')) {
886
+ // the test edition is published under xsct (http://snomed.info/xsct/31000003106/...)
887
+ if (!url.startsWith('http://snomed.info/sct') && !url.startsWith('http://snomed.info/xsct')) {
725
888
  return url;
726
889
  }
727
890
 
@@ -745,6 +908,7 @@ class RegistryModule {
745
908
  // Match edition code to description
746
909
  switch (editionCode) {
747
910
  case '900000000000207008': edition = 'Intl'; break;
911
+ case '31000003106': edition = 'Test'; break; // tx-ecosystem test edition (xsct)
748
912
  case '731000124108': edition = 'US'; break;
749
913
  case '32506021000036107': edition = 'AU'; break;
750
914
  case '449081005': edition = 'ES/Intl'; break;
@@ -1084,10 +1248,6 @@ class RegistryModule {
1084
1248
  this.logger.info(`Resolved CodeSystem ${url} for FHIR ${fhirVersion} (usage=${usage}, language=${language || 'none'}): ${matches}`);
1085
1249
  }
1086
1250
 
1087
- // If only authoritative servers are requested, filter results
1088
- if (authoritativeOnly === 'true' && result) {
1089
- result.candidates = [];
1090
- }
1091
1251
  if (acceptsHtml) {
1092
1252
  try {
1093
1253
  const startTime = Date.now();
@@ -1454,4 +1614,7 @@ class RegistryModule {
1454
1614
  }
1455
1615
  }
1456
1616
 
1617
+ RegistryModule.DISCOVERY_PARAMS = DISCOVERY_PARAMS;
1618
+ RegistryModule.RESOLVE_PARAMS = RESOLVE_PARAMS;
1619
+
1457
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;
@@ -79,7 +80,12 @@ const app = express();
79
80
 
80
81
  // Behind nginx (or any reverse proxy): honor X-Forwarded-* so req.protocol and
81
82
  // req.hostname reflect what the client actually requested, not the loopback hop.
82
- app.set('trust proxy', config.server.trustProxy ?? true);
83
+ // Only proxies on the same host or a private network are trusted by default. `true` would
84
+ // trust every X-Forwarded-For hop, so anyone who could reach the port directly could name
85
+ // their own IP address and walk around every rate limit (express-rate-limit refuses to be
86
+ // quiet about it: ERR_ERL_PERMISSIVE_TRUST_PROXY). See nginx.md.
87
+ const DEFAULT_TRUST_PROXY = 'loopback, linklocal, uniquelocal';
88
+ app.set('trust proxy', config.server.trustProxy ?? DEFAULT_TRUST_PROXY);
83
89
 
84
90
  const PORT = process.env.PORT || config.server.port || 3000;
85
91
 
@@ -105,10 +111,13 @@ app.use(cors(config.server.cors));
105
111
  const modules = {};
106
112
 
107
113
  let stats = null;
114
+ // periodic npm advisory check of the installed packages - see library/npm-audit.js
115
+ let npmAudit = null;
108
116
 
109
117
  // Initialize modules based on configuration
110
118
  async function initializeModules() {
111
119
  stats = new ServerStats(config.stats, serverLog);
120
+ npmAudit = new NpmAudit(config.npmAudit || {}, Logger.getInstance().child({ module: 'npm-audit' }), __dirname, stats);
112
121
 
113
122
  // Initialize SHL module
114
123
  if (config.modules?.shl?.enabled) {
@@ -300,7 +309,8 @@ async function loadTemplates() {
300
309
 
301
310
  async function buildRootPageContent() {
302
311
  stats.requestCount++;
303
- let content = '<div class="row mb-4">';
312
+ let content = npmAudit ? npmAudit.renderBanner() : '';
313
+ content += '<div class="row mb-4">';
304
314
  content += '<div class="col-12">';
305
315
 
306
316
  content += '<h3>Available Modules</h3>';
@@ -309,7 +319,7 @@ async function buildRootPageContent() {
309
319
  // Check which modules are enabled and add them to the list
310
320
  if (config.modules.packages.enabled) {
311
321
  content += '<li class="list-group-item">';
312
- 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>)';
313
323
  content += '</li>';
314
324
  }
315
325
 
@@ -334,7 +344,7 @@ async function buildRootPageContent() {
334
344
  if (config.modules.registry && config.modules.registry.enabled) {
335
345
  content += '<li class="list-group-item">';
336
346
  content += '<a href="/tx-reg" class="text-decoration-none">Terminology Server Registry</a>: ';
337
- 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>)';
338
348
  content += '</li>';
339
349
  }
340
350
 
@@ -369,7 +379,7 @@ async function buildRootPageContent() {
369
379
  if (config.modules?.testing?.enabled) {
370
380
  content += '<li class="list-group-item">';
371
381
  content += '<a href="/testing" class="text-decoration-none">Test Reports</a>: ';
372
- 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>)';
373
383
  content += '</li>';
374
384
  }
375
385
 
@@ -397,7 +407,9 @@ async function buildRootPageContent() {
397
407
  if (config.modules.tx.endpoints && config.modules.tx.endpoints.length > 0) {
398
408
  content += '<ul class="mt-2 mb-0">';
399
409
  for (const endpoint of config.modules.tx.endpoints) {
400
- 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>`;
401
413
  }
402
414
  content += '</ul>';
403
415
  }
@@ -653,6 +665,9 @@ app.get('/dashboard', async (req, res) => {
653
665
  startTime: stats.startTime
654
666
  });
655
667
  content += stats.taskDetails();
668
+ if (npmAudit) {
669
+ content += npmAudit.renderDashboard();
670
+ }
656
671
  content += `<p>Data: ${folders.dataDir()}</p>`;
657
672
 
658
673
  content = '<div class="row mb-4"><div class="col-12">' + content + '</div></div>';
@@ -785,6 +800,7 @@ async function startServer() {
785
800
  stats.markStarted();
786
801
  serverLog.info(`=== Server running on http://localhost:${PORT} ===`);
787
802
  });
803
+ npmAudit.start();
788
804
  if (modules.packages && config.modules.packages.enabled) {
789
805
  modules.packages.startInitialCrawler();
790
806
  }
@@ -812,6 +828,9 @@ async function startServer() {
812
828
  // Graceful shutdown
813
829
  process.on('SIGINT', async () => {
814
830
  serverLog.info('\nShutting down server...');
831
+ if (npmAudit) {
832
+ npmAudit.stop();
833
+ }
815
834
 
816
835
  // Shutdown all modules
817
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
+ };