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
package/registry/api.js CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  const { ServerRegistryUtilities } = require('./model');
4
4
  const escape = require('escape-html');
5
+ const { Utilities } = require('../library/utilities');
6
+
7
+ const RELEASE_VERSIONS = {
8
+ R2: '1.0',
9
+ R2B: '1.4',
10
+ R3: '3.0',
11
+ R4: '4.0',
12
+ R4B: '4.3',
13
+ R5: '5.0',
14
+ R6: '6.0'
15
+ };
16
+
17
+ // The ecosystem IG reports a server's security as boolean flags (open, password, token,
18
+ // oauth, smart, cert). The crawler records a single string; this maps it. 'api-key' is a
19
+ // token. The string itself is still reported as 'security' for existing clients.
20
+ function securityFlags(security) {
21
+ switch (security) {
22
+ case 'open': return { open: true };
23
+ case 'api-key': return { token: true };
24
+ case 'password': case 'token': case 'oauth': case 'smart': case 'cert':
25
+ return { [security]: true };
26
+ default: return {};
27
+ }
28
+ }
5
29
 
6
30
  class RegistryAPI {
7
31
  constructor(crawler) {
@@ -263,6 +287,10 @@ class RegistryAPI {
263
287
 
264
288
  return {
265
289
  lastRun: data.lastRun,
290
+ // the page footer: nothing to report until the first crawl has finished
291
+ crawlerStatus: data.lastRun ?
292
+ `last updated ${Utilities.describeAgo(data.lastRun)}, ${totalServers} server${totalServers === 1 ? '' : 's'}` :
293
+ 'not yet updated',
266
294
  outcome: data.outcome,
267
295
  registryCount: data.registries.length,
268
296
  serverCount: totalServers,
@@ -317,16 +345,11 @@ class RegistryAPI {
317
345
  });
318
346
  }
319
347
 
348
+ // Release codes (R4, r4b ...) to the version prefix they select; anything else (4.0.1,
349
+ // 4.0) is used as given. R4B and R2B can't be derived from the number alone.
320
350
  _normalizeFhirVersion(version) {
321
351
  if (!version) return version;
322
-
323
- // Convert R4 or r4 to 4.0, R5 or r5 to 5.0, etc.
324
- const rMatch = /^[rR](\d+)$/.exec(version);
325
- if (rMatch) {
326
- return `${rMatch[1]}.0`;
327
- }
328
-
329
- return version;
352
+ return RELEASE_VERSIONS[version.toUpperCase()] || version;
330
353
  }
331
354
 
332
355
  /**
@@ -471,38 +494,9 @@ class RegistryAPI {
471
494
  });
472
495
  });
473
496
 
474
- // NEW: Fallback - if no matches found, check for authoritative pattern matches
475
- if (authMatches.length === 0 && result.candidates.length === 0) {
476
- data.registries.forEach(registry => {
477
- registry.servers.forEach(server => {
478
- // Excluded content stays hidden in the fallback path too
479
- if (server.isExcludedTarget(codeSystem)) return;
480
-
481
- // Check if server supports the requested usage tag
482
- if (server.usageList.length === 0 ||
483
- (usage && server.usageList.includes(usage))) {
484
-
485
- // Check if server is authoritative for this code system
486
- // (taking language specific claims into account)
487
- const authMatch = server.matchAuthCS(codeSystem, requestLangs);
488
-
489
- if (authMatch.isAuth) {
490
- server.versions.forEach(version => {
491
- if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) {
492
- authMatches.push({
493
- entry: this.createServerEntry(server, version, null, authMatch),
494
- score: authMatch.score
495
- });
496
- if (!matchedServers.includes(server.code)) {
497
- matchedServers.push(server.code);
498
- }
499
- }
500
- });
501
- }
502
- }
503
- });
504
- });
505
- }
497
+ // No fallback to servers that claim authority but don't host the code system: per the
498
+ // ecosystem IG, 'Servers are not listed as authoritative unless they actually host the
499
+ // CodeSystem(+version) in the request'.
506
500
 
507
501
  // Language specific matches rank before authoritative-list matches, most specific
508
502
  // match first. Array.prototype.sort is stable, so entries with equal scores (e.g.
@@ -598,6 +592,86 @@ class RegistryAPI {
598
592
  };
599
593
  }
600
594
 
595
+ /**
596
+ * The ecosystem Discovery API (GET {root} as JSON): one row per server endpoint.
597
+ *
598
+ * Filters, all optional: registry and server (codes), fhirVersion (RX or M.n.p), url (a
599
+ * code system, url or url|version), authoritativeOnly, language (only with url).
600
+ *
601
+ * With url, only endpoints that host that code system are listed (an excluded code
602
+ * system hides the server), and the row's 'candidate' list says whether it's hosted but
603
+ * not claimed; authoritativeOnly then keeps only the endpoints authoritative for it.
604
+ * Without url, authoritative/authoritative-valuesets are the server's claim masks and the
605
+ * candidate lists are omitted - listing everything a server hosts would be huge.
606
+ */
607
+ discover(params = {}) {
608
+ const { registry = '', server = '', fhirVersion = '', url = '', language = '' } = params;
609
+ const authoritativeOnly = params.authoritativeOnly === true || params.authoritativeOnly === 'true';
610
+ const normalizedVersion = this._normalizeFhirVersion(fhirVersion);
611
+ const requestLangs = url ? ServerRegistryUtilities.parseAcceptLanguage(language) : null;
612
+ const data = this.crawler.getData();
613
+
614
+ const authRows = [];
615
+ const otherRows = [];
616
+ data.registries.forEach(reg => {
617
+ if (registry && reg.code !== registry) return;
618
+ reg.servers.forEach(srv => {
619
+ if (server && srv.code !== server) return;
620
+ if (url && srv.isExcludedTarget(url)) return;
621
+
622
+ const authMatch = url ? srv.matchAuthCS(url, requestLangs) : { isAuth: false, scoped: false };
623
+
624
+ srv.versions.forEach(version => {
625
+ if (normalizedVersion && !ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) {
626
+ return;
627
+ }
628
+ let hosted = false;
629
+ if (url) {
630
+ hosted = ServerRegistryUtilities.hasMatchingCodeSystem(url, version.codeSystems, false, {});
631
+ if (!hosted || (authoritativeOnly && !authMatch.isAuth)) {
632
+ return;
633
+ }
634
+ }
635
+
636
+ const row = {
637
+ 'server-name': srv.name,
638
+ 'server-code': srv.code,
639
+ 'registry-name': reg.name,
640
+ 'registry-code': reg.code,
641
+ 'registry-url': reg.address,
642
+ url: version.address,
643
+ fhirVersion: version.version,
644
+ error: version.error || null,
645
+ 'last-success': version.lastSuccess ? Math.floor(Date.now() - new Date(version.lastSuccess).getTime()) : null,
646
+ systems: version.codeSystems.length,
647
+ authoritative: [...srv.authCSList],
648
+ 'authoritative-valuesets': [...srv.authVSList]
649
+ };
650
+ if (url && !authMatch.isAuth) {
651
+ row.candidate = [url];
652
+ }
653
+ if (authMatch.isAuth && authMatch.scoped) {
654
+ row.languages = [authMatch.tag];
655
+ }
656
+ if (version.security) {
657
+ row.security = version.security;
658
+ Object.assign(row, securityFlags(version.security));
659
+ }
660
+ (authMatch.isAuth ? authRows : otherRows).push({ row, score: authMatch.score || 0 });
661
+ });
662
+ });
663
+ });
664
+
665
+ // Endpoints authoritative for url first (language specific matches before the rest;
666
+ // sort is stable, so registration order is kept otherwise)
667
+ authRows.sort((a, b) => a.score - b.score);
668
+ return {
669
+ 'last-update': data.lastRun ? new Date(data.lastRun).toISOString() : null,
670
+ 'master-url': data.address,
671
+ results: [...authRows, ...otherRows].map(r => r.row)
672
+ };
673
+ }
674
+
601
675
  _cleanEmptyArrays(result) {
602
676
  const cleanedResult = { ...result };
603
677
 
@@ -620,11 +694,13 @@ class RegistryAPI {
620
694
  createServerEntry(server, version, content = null, authMatch = null) {
621
695
  const entry = {
622
696
  'server-name': server.name,
623
- url: version.address
697
+ url: version.address,
698
+ fhirVersion: version.version
624
699
  };
625
700
 
626
701
  if (version.security) {
627
702
  entry.security = version.security;
703
+ Object.assign(entry, securityFlags(version.security));
628
704
  }
629
705
  if (server.accessInfo) {
630
706
  entry.access_info = server.accessInfo;
@@ -333,11 +333,33 @@ class RegistryCrawler {
333
333
  this.addLogEntry('error', `Server ${version.address}: Error after ${elapsed}ms: ${error.message}`);
334
334
  version.error = error.message;
335
335
  version.lastTat = `${elapsed}ms`;
336
+ this.carryForwardSoftware(version);
336
337
  }
337
338
 
338
339
  return version;
339
340
  }
340
341
 
342
+ /**
343
+ * A server that can't be reached this time is still running whatever it was running
344
+ * last time we saw it - keep that, so the software page doesn't lose track of it
345
+ */
346
+ carryForwardSoftware(version) {
347
+ if (version.software && version.software !== 'unknown') {
348
+ return;
349
+ }
350
+ for (const registry of (this.currentData && this.currentData.registries) || []) {
351
+ for (const server of registry.servers || []) {
352
+ for (const prev of server.versions || []) {
353
+ if (prev.address === version.address && prev.software) {
354
+ version.software = prev.software;
355
+ version.softwareVersion = prev.softwareVersion || '';
356
+ return;
357
+ }
358
+ }
359
+ }
360
+ }
361
+ }
362
+
341
363
  /**
342
364
  * Process an R3 server
343
365
  */
@@ -348,6 +370,7 @@ class RegistryCrawler {
348
370
 
349
371
  version.version = capability.fhirVersion || '3.0.2';
350
372
  version.software = capability.software ? capability.software.name : "unknown";
373
+ version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
351
374
 
352
375
  // Get terminology capabilities (R3 uses Parameters resource)
353
376
  try {
@@ -392,6 +415,7 @@ class RegistryCrawler {
392
415
 
393
416
  version.version = capability.fhirVersion || defVersion;
394
417
  version.software = capability.software ? capability.software.name : "unknown";
418
+ version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
395
419
 
396
420
  let set = new Set();
397
421
 
@@ -0,0 +1,170 @@
1
+ // registry/fhirsmith-releases.js
2
+ // The list of FHIRsmith releases and their dates, so the registry can say how old the
3
+ // FHIRsmith version a registered server reports is.
4
+ //
5
+ // The list comes from the GitHub releases API, refreshed with each crawl. Until that
6
+ // succeeds (or if it can't be reached), the dated headings in this server's own
7
+ // CHANGELOG.md are used instead - those only go up to this server's own version.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const axios = require('axios');
12
+
13
+ const DEFAULT_RELEASES_URL = 'https://api.github.com/repos/HealthIntersections/FHIRsmith/releases';
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+
16
+ // "v0.14.1" -> [0, 14, 1]; anything that isn't n.n.n (optionally with a -suffix) -> null
17
+ function parseVersion(v) {
18
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim());
19
+ if (!m) {
20
+ return null;
21
+ }
22
+ return { parts: [Number(m[1]), Number(m[2]), Number(m[3])], suffix: m[4] || '' };
23
+ }
24
+
25
+ function compareParts(a, b) {
26
+ for (let i = 0; i < 3; i++) {
27
+ if (a[i] !== b[i]) {
28
+ return a[i] - b[i];
29
+ }
30
+ }
31
+ return 0;
32
+ }
33
+
34
+ function isFhirsmith(softwareName) {
35
+ return /fhirsmith/i.test(softwareName || '');
36
+ }
37
+
38
+ class FhirsmithReleases {
39
+ constructor(config = {}, logger = null) {
40
+ this.url = config.releasesUrl || DEFAULT_RELEASES_URL;
41
+ this.timeout = config.timeout || 30000;
42
+ this.userAgent = config.userAgent || 'FHIRRegistryServer/1.0';
43
+ this.logger = logger;
44
+ this.releases = []; // [{version: '0.14.1', parts: [0,14,1], date: Date}], newest first
45
+ this.source = 'none';
46
+ this.lastRefresh = null;
47
+ }
48
+
49
+ setReleases(list, source) {
50
+ const seen = new Set();
51
+ const releases = [];
52
+ for (const r of list) {
53
+ const pv = parseVersion(r.version);
54
+ const date = r.date instanceof Date ? r.date : new Date(r.date);
55
+ // a release is n.n.n - pre-release suffixes are not tracked
56
+ if (!pv || pv.suffix || isNaN(date.getTime())) {
57
+ continue;
58
+ }
59
+ const key = pv.parts.join('.');
60
+ if (!seen.has(key)) {
61
+ seen.add(key);
62
+ releases.push({ version: key, parts: pv.parts, date });
63
+ }
64
+ }
65
+ releases.sort((a, b) => compareParts(b.parts, a.parts));
66
+ this.releases = releases;
67
+ this.source = source;
68
+ }
69
+
70
+ /**
71
+ * Read the dated release headings from a CHANGELOG.md - "## [v0.13.4] - 2026-09-17"
72
+ */
73
+ loadFromChangelog(changelogPath = path.join(__dirname, '..', 'CHANGELOG.md')) {
74
+ try {
75
+ const text = fs.readFileSync(changelogPath, 'utf8');
76
+ const list = [];
77
+ const re = /^##\s*\[v?(\d+\.\d+\.\d+)\]\s*-\s*(\d{4}-\d{2}-\d{2})\s*$/gm;
78
+ let m;
79
+ while ((m = re.exec(text)) !== null) {
80
+ list.push({ version: m[1], date: new Date(m[2] + 'T00:00:00Z') });
81
+ }
82
+ this.setReleases(list, 'CHANGELOG.md');
83
+ } catch (error) {
84
+ if (this.logger) {
85
+ this.logger.warn('Could not read FHIRsmith release dates from CHANGELOG.md: ' + error.message);
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Fetch the release list from GitHub. On failure the current list is kept.
92
+ */
93
+ async refresh() {
94
+ try {
95
+ const list = [];
96
+ for (let page = 1; page <= 10; page++) {
97
+ const response = await axios.get(`${this.url}?per_page=100&page=${page}`, {
98
+ timeout: this.timeout,
99
+ headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': this.userAgent }
100
+ });
101
+ const batch = Array.isArray(response.data) ? response.data : [];
102
+ for (const r of batch) {
103
+ if (!r.draft && !r.prerelease && r.tag_name && r.published_at) {
104
+ list.push({ version: r.tag_name, date: r.published_at });
105
+ }
106
+ }
107
+ if (batch.length < 100) {
108
+ break;
109
+ }
110
+ }
111
+ if (list.length > 0) {
112
+ this.setReleases(list, 'GitHub');
113
+ this.lastRefresh = new Date();
114
+ }
115
+ } catch (error) {
116
+ if (this.logger) {
117
+ this.logger.warn('Could not fetch FHIRsmith releases from GitHub: ' + error.message);
118
+ }
119
+ }
120
+ }
121
+
122
+ latest() {
123
+ return this.releases.length > 0 ? this.releases[0] : null;
124
+ }
125
+
126
+ /**
127
+ * How a reported FHIRsmith version stands against the releases:
128
+ * status: 'current' | 'outdated' | 'dev' (a build between/after releases) | 'unknown'
129
+ * release: the release it is (or, for 'dev', the most recent release before it)
130
+ * ageDays: days since that release came out ('current'/'outdated' only)
131
+ * behind: how many releases have come out since
132
+ */
133
+ describe(reportedVersion, now = Date.now()) {
134
+ const pv = parseVersion(reportedVersion);
135
+ if (!pv || this.releases.length === 0) {
136
+ return { status: 'unknown' };
137
+ }
138
+ const behind = this.releases.filter(r => compareParts(r.parts, pv.parts) > 0).length;
139
+ const exact = !pv.suffix ? this.releases.find(r => compareParts(r.parts, pv.parts) === 0) : null;
140
+ if (exact) {
141
+ return {
142
+ status: behind === 0 ? 'current' : 'outdated',
143
+ release: exact,
144
+ ageDays: Math.max(0, Math.floor((now - exact.date.getTime()) / DAY_MS)),
145
+ behind
146
+ };
147
+ }
148
+ // a snapshot (0.14.2-snapshot), or a version we have no release for
149
+ const base = this.releases.find(r => compareParts(r.parts, pv.parts) < 0) || null;
150
+ return { status: 'dev', release: base, behind };
151
+ }
152
+ }
153
+
154
+ function describeAge(days) {
155
+ if (days < 1) {
156
+ return 'today';
157
+ }
158
+ if (days < 14) {
159
+ return `${days} day${days === 1 ? '' : 's'}`;
160
+ }
161
+ if (days < 60) {
162
+ return `${Math.floor(days / 7)} weeks`;
163
+ }
164
+ if (days < 730) {
165
+ return `${Math.floor(days / 30.44)} months`;
166
+ }
167
+ return `${(days / 365.25).toFixed(1)} years`;
168
+ }
169
+
170
+ module.exports = { FhirsmithReleases, isFhirsmith, parseVersion, describeAge };
package/registry/model.js CHANGED
@@ -11,6 +11,7 @@ class ServerVersionInformation {
11
11
  this.lastSuccess = null; // Date object
12
12
  this.lastTat = '';
13
13
  this.software = ''; // what software is running
14
+ this.softwareVersion = ''; // CapabilityStatement.software.version, if the server reports one
14
15
  this.codeSystems = []; // Array of strings (sorted, unique)
15
16
  this.valueSets = []; // Array of strings (sorted, unique)
16
17
  }
@@ -58,6 +59,7 @@ class ServerVersionInformation {
58
59
  lastTat: this.lastTat,
59
60
  terminologies: this.codeSystems,
60
61
  software: this.software,
62
+ 'software-version': this.softwareVersion,
61
63
  valuesets: this.valueSets
62
64
  };
63
65
  }
@@ -71,6 +73,7 @@ class ServerVersionInformation {
71
73
  instance.lastSuccess = json['last-success'] ? new Date(json['last-success']) : null;
72
74
  instance.lastTat = json.lastTat || '';
73
75
  instance.software = json.software;
76
+ instance.softwareVersion = json['software-version'] || '';
74
77
  instance.codeSystems = json.terminologies || [];
75
78
  instance.valueSets = json.valuesets || [];
76
79
  return instance;
@@ -561,6 +564,13 @@ class ServerRegistryUtilities {
561
564
  } else {
562
565
  // Otherwise do exact matching on both full and base URL
563
566
  ok = vurl === cs || vurl === baseCs;
567
+ // A SNOMED CT version URI extends its edition URI, so a server hosting
568
+ // sct|.../{edition}/version/{date} hosts the edition sct|.../{edition}. Servers
569
+ // list only full versions, so without this an edition-level request would never
570
+ // find the servers that host the edition.
571
+ if (!ok && noVersionIndependentMatching && cs.includes('|')) {
572
+ ok = vurl.startsWith(cs + '/');
573
+ }
564
574
  }
565
575
  if (ok && content) {
566
576
  content.content = item.content;
@@ -0,0 +1,13 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ // The terminology registry's OpenAPI description. openapi.yaml is the source of truth; see
8
+ // the comment at its top.
9
+
10
+ const path = require('path');
11
+ const { createOpenApiDoc } = require('../library/openapi-doc');
12
+
13
+ module.exports = createOpenApiDoc(path.join(__dirname, 'openapi.yaml'), '/tx-reg');