fhirsmith 0.14.1 → 0.15.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.
- package/CHANGELOG.md +58 -0
- package/README.md +3 -2
- package/config-template.json +9 -0
- package/library/changelog.js +57 -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 +41 -0
- package/registry/fhirsmith-releases.js +167 -0
- package/registry/model.js +13 -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 +198 -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 +78 -4
- package/tx/cm/cm-api.js +25 -10
- package/tx/cm/cm-database.js +10 -5
- package/tx/cm/cm-package.js +61 -15
- package/tx/cs/cs-provider-api.js +22 -7
- package/tx/cs/cs-provider-list.js +10 -9
- 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/resource-ids.js +107 -0
- package/tx/library/valueset.js +16 -0
- package/tx/library.js +113 -26
- package/tx/ocl/cm-ocl.cjs +23 -15
- package/tx/ocl/cs-ocl.cjs +9 -10
- package/tx/ocl/vs-ocl.cjs +17 -17
- 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 +90 -14
- package/tx/tx-html.js +6 -0
- package/tx/tx.fhir.org.yml +16 -8
- package/tx/tx.js +62 -1
- package/tx/vs/vs-api.js +25 -10
- package/tx/vs/vs-database.js +14 -9
- package/tx/vs/vs-package.js +58 -31
- package/tx/vs/vs-vsac.js +20 -5
- package/tx/workers/expand.js +35 -16
- package/tx/workers/metadata.js +2 -14
- package/tx/workers/read.js +3 -18
- 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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Package Server
|
|
2
|
+
|
|
3
|
+
A registry of FHIR NPM packages. It crawls the package feeds listed in the FHIR IG
|
|
4
|
+
registry, stores every package version it finds, and serves them using the npm
|
|
5
|
+
registry protocol, with FHIR-specific search on top (by canonical URL, FHIR version
|
|
6
|
+
and dependency). It is the server behind packages2.fhir.org.
|
|
7
|
+
|
|
8
|
+
Its main clients are the HL7 Java tools (`org.hl7.fhir.utilities.npm.PackageClient`,
|
|
9
|
+
used by the IG Publisher and validator). Package documents, tarball URLs and search
|
|
10
|
+
results follow npm's formats, so npm clients can point `--registry` at
|
|
11
|
+
`<server>/packages`.
|
|
12
|
+
|
|
13
|
+
## API
|
|
14
|
+
|
|
15
|
+
The public API is described by an OpenAPI 3.1 spec:
|
|
16
|
+
|
|
17
|
+
| URL | |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `/packages/openapi` | Browsable reference (HTML), with a "try it" form for each GET operation |
|
|
20
|
+
| `/packages/openapi.json` | The spec as JSON |
|
|
21
|
+
| `/packages/openapi.yaml` | The spec as YAML (the source file, [openapi.yaml](openapi.yaml)) |
|
|
22
|
+
|
|
23
|
+
Every response from the package server carries a
|
|
24
|
+
`Link: </packages/openapi.json>; rel="service-desc"` header (RFC 8631), and the
|
|
25
|
+
HTML pages carry the matching `<link>` element.
|
|
26
|
+
|
|
27
|
+
In summary:
|
|
28
|
+
|
|
29
|
+
| Endpoint | Purpose |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `GET /packages/catalog` | Search packages: `name`, `canonical`, `pkgcanonical`, `fhirversion`, `dependency`, `dependson`, `sort` |
|
|
32
|
+
| `GET /packages/-/v1/search` | npm search API: `text`, `size`, `from`, plus the `/catalog` parameters |
|
|
33
|
+
| `GET /packages/{id}` | npm package document: all versions, dependencies, tarball URLs |
|
|
34
|
+
| `GET /packages/{id}/{version}` | Download a package tarball |
|
|
35
|
+
| `GET /packages/updates` | Versions published since a date |
|
|
36
|
+
| `GET /packages/broken` | Package versions whose dependencies this registry doesn't hold |
|
|
37
|
+
|
|
38
|
+
Most endpoints return an HTML page instead of JSON when the request's `Accept`
|
|
39
|
+
header contains `text/html`. The JSON is the contract; the HTML is for people.
|
|
40
|
+
|
|
41
|
+
Query parameters are validated strictly. An unknown parameter, a repeated parameter,
|
|
42
|
+
or a value that's too long or badly formed is a 400, not silently ignored.
|
|
43
|
+
|
|
44
|
+
Not in the spec, deliberately:
|
|
45
|
+
|
|
46
|
+
* Operational pages: `/packages/stats`, `/packages/log` (the last crawler run),
|
|
47
|
+
`/packages/status`.
|
|
48
|
+
* `POST /packages/crawl`, which runs a full crawl on demand. It requires the
|
|
49
|
+
`x-crawl-token` header to match the `crawlToken` setting, and is disabled when no
|
|
50
|
+
token is configured.
|
|
51
|
+
|
|
52
|
+
### Keeping the spec honest
|
|
53
|
+
|
|
54
|
+
[openapi.yaml](openapi.yaml) is maintained by hand. `tests/packages/openapi.test.js`
|
|
55
|
+
fails if:
|
|
56
|
+
|
|
57
|
+
* a route is added to the router without being described in the spec, or listed in
|
|
58
|
+
the test's `EXCLUDED` table with a reason;
|
|
59
|
+
* the spec describes a route the router doesn't have;
|
|
60
|
+
* a query or path parameter's name, length limit, pattern or default differs from the
|
|
61
|
+
validation rules in `packages.js` (`SEARCH_PARAMS`, `V1_SEARCH_PARAMS`,
|
|
62
|
+
`UPDATES_PARAMS`, `BROKEN_PARAMS`, `PATH_PARAMS`).
|
|
63
|
+
|
|
64
|
+
So when you change a route or a parameter rule, change `openapi.yaml` with it.
|
|
65
|
+
`tests/packages/search.test.js` covers the search behaviour itself against an
|
|
66
|
+
in-memory database.
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
In the server config, under `modules.packages`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
"packages": {
|
|
74
|
+
"enabled": true,
|
|
75
|
+
"database": "packages.db",
|
|
76
|
+
"mirrorPath": "/absolute/path/to/mirror",
|
|
77
|
+
"crawlToken": "a-long-random-secret",
|
|
78
|
+
"crawler": {
|
|
79
|
+
"enabled": true,
|
|
80
|
+
"schedule": "0 * * * *"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Setting | |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `database` | The SQLite database. A relative path is resolved against the data directory's `packages` folder. Created on first run. |
|
|
88
|
+
| `mirrorPath` | Directory where the crawler saves a copy of each tarball, as `{id}-{version}.tgz`. A scoped id `@scope/name` is saved as `$scope$name`. |
|
|
89
|
+
| `bucketPath` | Optional. If set, downloads redirect to `{bucketPath}/{file}` and tarball URLs point there, instead of being served from the database. The file names are the mirror's, so the bucket is expected to be a copy of the mirror. |
|
|
90
|
+
| `baseUrl` | Optional. The public base URL used when building package URLs. Defaults to the request's host. |
|
|
91
|
+
| `crawlToken` | Shared secret for `POST /packages/crawl`. Omit it to disable that endpoint; the scheduled crawler is unaffected. |
|
|
92
|
+
| `crawler.enabled` | Run the crawler at startup and on the schedule. |
|
|
93
|
+
| `crawler.schedule` | Cron expression for crawls. |
|
|
94
|
+
| `masterUrl` | The list of feeds to crawl. Defaults to `https://fhir.github.io/ig-registry/package-feeds.json`. |
|
|
95
|
+
| `localFeedDirs` | Optional. Directories feeds may be read from as local files (for testing). |
|
|
96
|
+
| `allowPrivateAddresses` | Optional, for testing only. Lets the crawler fetch from private and loopback addresses, which it otherwise refuses (SSRF protection). |
|
|
97
|
+
|
|
98
|
+
## Crawling
|
|
99
|
+
|
|
100
|
+
Each crawl reads the master feed list, then each RSS feed in it (Simplifier last).
|
|
101
|
+
For each item, it downloads the package, checks the id, version and canonical, and
|
|
102
|
+
stores it.
|
|
103
|
+
|
|
104
|
+
* The master list's `package-restrictions` say which feeds may publish which package
|
|
105
|
+
ids. A package from a feed that isn't allowed to publish it is skipped.
|
|
106
|
+
* Items marked `notForPublication` are skipped.
|
|
107
|
+
* Feeds can be paginated (RFC 5005 `rel="next"`). The first page is read on every
|
|
108
|
+
crawl; older pages are read once and remembered in the `FeedPages` table.
|
|
109
|
+
* A feed that rate-limits (HTTP 429) is abandoned for that crawl and retried next
|
|
110
|
+
time.
|
|
111
|
+
|
|
112
|
+
`/packages/log` shows what the last crawl did, feed by feed and item by item.
|
|
113
|
+
|
|
114
|
+
## Storage
|
|
115
|
+
|
|
116
|
+
Everything is in the SQLite database. `PackageVersions` holds each version's
|
|
117
|
+
metadata and the tarball itself. `Packages` holds one row per package id, with its
|
|
118
|
+
current version and download count. `PackageDependencies` (stored as `id@version`),
|
|
119
|
+
`PackageFHIRVersions` and `PackageURLs` (the canonical URLs of the resources in each
|
|
120
|
+
version) support search.
|
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
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
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;
|
package/registry/crawler.js
CHANGED
|
@@ -333,11 +333,48 @@ 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
|
+
* CapabilityStatement.software.releaseDate, if it means anything. A date within a few
|
|
344
|
+
* minutes of now is the server's clock, not a release date: FHIRsmith up to 0.14.2 filled
|
|
345
|
+
* it in with the time of the request, and other software may do the same.
|
|
346
|
+
*/
|
|
347
|
+
reportedReleaseDate(software, now = Date.now()) {
|
|
348
|
+
const value = software && software.releaseDate;
|
|
349
|
+
const t = value ? new Date(value).getTime() : NaN;
|
|
350
|
+
if (isNaN(t) || Math.abs(now - t) < 10 * 60 * 1000) {
|
|
351
|
+
return '';
|
|
352
|
+
}
|
|
353
|
+
return String(value);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A server that can't be reached this time is still running whatever it was running
|
|
358
|
+
* last time we saw it - keep that, so the software page doesn't lose track of it
|
|
359
|
+
*/
|
|
360
|
+
carryForwardSoftware(version) {
|
|
361
|
+
if (version.software && version.software !== 'unknown') {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
for (const registry of (this.currentData && this.currentData.registries) || []) {
|
|
365
|
+
for (const server of registry.servers || []) {
|
|
366
|
+
for (const prev of server.versions || []) {
|
|
367
|
+
if (prev.address === version.address && prev.software) {
|
|
368
|
+
version.software = prev.software;
|
|
369
|
+
version.softwareVersion = prev.softwareVersion || '';
|
|
370
|
+
version.softwareReleaseDate = prev.softwareReleaseDate || '';
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
341
378
|
/**
|
|
342
379
|
* Process an R3 server
|
|
343
380
|
*/
|
|
@@ -348,6 +385,8 @@ class RegistryCrawler {
|
|
|
348
385
|
|
|
349
386
|
version.version = capability.fhirVersion || '3.0.2';
|
|
350
387
|
version.software = capability.software ? capability.software.name : "unknown";
|
|
388
|
+
version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
|
|
389
|
+
version.softwareReleaseDate = this.reportedReleaseDate(capability.software);
|
|
351
390
|
|
|
352
391
|
// Get terminology capabilities (R3 uses Parameters resource)
|
|
353
392
|
try {
|
|
@@ -392,6 +431,8 @@ class RegistryCrawler {
|
|
|
392
431
|
|
|
393
432
|
version.version = capability.fhirVersion || defVersion;
|
|
394
433
|
version.software = capability.software ? capability.software.name : "unknown";
|
|
434
|
+
version.softwareVersion = capability.software && capability.software.version ? String(capability.software.version) : '';
|
|
435
|
+
version.softwareReleaseDate = this.reportedReleaseDate(capability.software);
|
|
395
436
|
|
|
396
437
|
let set = new Set();
|
|
397
438
|
|
|
@@ -0,0 +1,167 @@
|
|
|
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 { readReleaseDates } = require('../library/changelog');
|
|
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 list = [];
|
|
76
|
+
for (const [version, date] of readReleaseDates(changelogPath)) {
|
|
77
|
+
list.push({ version, date: new Date(date + 'T00:00:00Z') });
|
|
78
|
+
}
|
|
79
|
+
this.setReleases(list, 'CHANGELOG.md');
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (this.logger) {
|
|
82
|
+
this.logger.warn('Could not read FHIRsmith release dates from CHANGELOG.md: ' + error.message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Fetch the release list from GitHub. On failure the current list is kept.
|
|
89
|
+
*/
|
|
90
|
+
async refresh() {
|
|
91
|
+
try {
|
|
92
|
+
const list = [];
|
|
93
|
+
for (let page = 1; page <= 10; page++) {
|
|
94
|
+
const response = await axios.get(`${this.url}?per_page=100&page=${page}`, {
|
|
95
|
+
timeout: this.timeout,
|
|
96
|
+
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': this.userAgent }
|
|
97
|
+
});
|
|
98
|
+
const batch = Array.isArray(response.data) ? response.data : [];
|
|
99
|
+
for (const r of batch) {
|
|
100
|
+
if (!r.draft && !r.prerelease && r.tag_name && r.published_at) {
|
|
101
|
+
list.push({ version: r.tag_name, date: r.published_at });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (batch.length < 100) {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (list.length > 0) {
|
|
109
|
+
this.setReleases(list, 'GitHub');
|
|
110
|
+
this.lastRefresh = new Date();
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (this.logger) {
|
|
114
|
+
this.logger.warn('Could not fetch FHIRsmith releases from GitHub: ' + error.message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
latest() {
|
|
120
|
+
return this.releases.length > 0 ? this.releases[0] : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* How a reported FHIRsmith version stands against the releases:
|
|
125
|
+
* status: 'current' | 'outdated' | 'dev' (a build between/after releases) | 'unknown'
|
|
126
|
+
* release: the release it is (or, for 'dev', the most recent release before it)
|
|
127
|
+
* ageDays: days since that release came out ('current'/'outdated' only)
|
|
128
|
+
* behind: how many releases have come out since
|
|
129
|
+
*/
|
|
130
|
+
describe(reportedVersion, now = Date.now()) {
|
|
131
|
+
const pv = parseVersion(reportedVersion);
|
|
132
|
+
if (!pv || this.releases.length === 0) {
|
|
133
|
+
return { status: 'unknown' };
|
|
134
|
+
}
|
|
135
|
+
const behind = this.releases.filter(r => compareParts(r.parts, pv.parts) > 0).length;
|
|
136
|
+
const exact = !pv.suffix ? this.releases.find(r => compareParts(r.parts, pv.parts) === 0) : null;
|
|
137
|
+
if (exact) {
|
|
138
|
+
return {
|
|
139
|
+
status: behind === 0 ? 'current' : 'outdated',
|
|
140
|
+
release: exact,
|
|
141
|
+
ageDays: Math.max(0, Math.floor((now - exact.date.getTime()) / DAY_MS)),
|
|
142
|
+
behind
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// a snapshot (0.14.2-snapshot), or a version we have no release for
|
|
146
|
+
const base = this.releases.find(r => compareParts(r.parts, pv.parts) < 0) || null;
|
|
147
|
+
return { status: 'dev', release: base, behind };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function describeAge(days) {
|
|
152
|
+
if (days < 1) {
|
|
153
|
+
return 'today';
|
|
154
|
+
}
|
|
155
|
+
if (days < 14) {
|
|
156
|
+
return `${days} day${days === 1 ? '' : 's'}`;
|
|
157
|
+
}
|
|
158
|
+
if (days < 60) {
|
|
159
|
+
return `${Math.floor(days / 7)} weeks`;
|
|
160
|
+
}
|
|
161
|
+
if (days < 730) {
|
|
162
|
+
return `${Math.floor(days / 30.44)} months`;
|
|
163
|
+
}
|
|
164
|
+
return `${(days / 365.25).toFixed(1)} years`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { FhirsmithReleases, isFhirsmith, parseVersion, describeAge };
|
package/registry/model.js
CHANGED
|
@@ -11,6 +11,8 @@ 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
|
|
15
|
+
this.softwareReleaseDate = ''; // CapabilityStatement.software.releaseDate, if it is a real date
|
|
14
16
|
this.codeSystems = []; // Array of strings (sorted, unique)
|
|
15
17
|
this.valueSets = []; // Array of strings (sorted, unique)
|
|
16
18
|
}
|
|
@@ -58,6 +60,8 @@ class ServerVersionInformation {
|
|
|
58
60
|
lastTat: this.lastTat,
|
|
59
61
|
terminologies: this.codeSystems,
|
|
60
62
|
software: this.software,
|
|
63
|
+
'software-version': this.softwareVersion,
|
|
64
|
+
'software-release-date': this.softwareReleaseDate,
|
|
61
65
|
valuesets: this.valueSets
|
|
62
66
|
};
|
|
63
67
|
}
|
|
@@ -71,6 +75,8 @@ class ServerVersionInformation {
|
|
|
71
75
|
instance.lastSuccess = json['last-success'] ? new Date(json['last-success']) : null;
|
|
72
76
|
instance.lastTat = json.lastTat || '';
|
|
73
77
|
instance.software = json.software;
|
|
78
|
+
instance.softwareVersion = json['software-version'] || '';
|
|
79
|
+
instance.softwareReleaseDate = json['software-release-date'] || '';
|
|
74
80
|
instance.codeSystems = json.terminologies || [];
|
|
75
81
|
instance.valueSets = json.valuesets || [];
|
|
76
82
|
return instance;
|
|
@@ -561,6 +567,13 @@ class ServerRegistryUtilities {
|
|
|
561
567
|
} else {
|
|
562
568
|
// Otherwise do exact matching on both full and base URL
|
|
563
569
|
ok = vurl === cs || vurl === baseCs;
|
|
570
|
+
// A SNOMED CT version URI extends its edition URI, so a server hosting
|
|
571
|
+
// sct|.../{edition}/version/{date} hosts the edition sct|.../{edition}. Servers
|
|
572
|
+
// list only full versions, so without this an edition-level request would never
|
|
573
|
+
// find the servers that host the edition.
|
|
574
|
+
if (!ok && noVersionIndependentMatching && cs.includes('|')) {
|
|
575
|
+
ok = vurl.startsWith(cs + '/');
|
|
576
|
+
}
|
|
564
577
|
}
|
|
565
578
|
if (ok && content) {
|
|
566
579
|
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');
|