fhirsmith 0.13.4 → 0.14.1
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 +61 -1
- package/README.md +16 -3
- package/config-template.json +81 -1
- package/library/cgroup-memory.js +113 -0
- package/library/folder-setup.js +8 -1
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +399 -0
- package/package.json +3 -2
- package/publisher/git-remote.js +141 -0
- package/publisher/github-release.js +195 -0
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +241 -56
- package/registry/registry.js +3 -1
- package/server.js +57 -15
- package/stats.js +19 -13
- package/testing/readme.md +144 -0
- package/testing/render.js +723 -0
- package/testing/search.js +378 -0
- package/testing/store.js +410 -0
- package/testing/testing-template.html +126 -0
- package/testing/testing.js +760 -0
- package/translations/Messages.properties +32 -0
- package/tx/closure/closure-store.js +396 -0
- package/tx/cs/cs-api.js +6 -1
- package/tx/cs/cs-rxnorm.js +35 -8
- package/tx/cs/cs-snomed.js +19 -69
- package/tx/html/codesystem-operations.liquid +177 -0
- package/tx/html/ecl-panel.liquid +93 -0
- package/tx/html/home-metrics.liquid +39 -76
- package/tx/html/tx-template.html +1 -0
- package/tx/library/conceptmap.js +22 -6
- package/tx/library/designations.js +12 -3
- package/tx/library/operation-outcome.js +3 -1
- package/tx/library/renderer.js +42 -5
- package/tx/operation-context.js +80 -17
- package/tx/provider.js +85 -0
- package/tx/sct/editions.js +70 -0
- package/tx/sct/expressions.js +128 -44
- package/tx/tx-html.js +426 -44
- package/tx/tx.js +117 -9
- package/tx/workers/cache-control.js +2 -2
- package/tx/workers/closure.js +483 -13
- package/tx/workers/expand.js +2 -2
- package/tx/workers/metadata.js +8 -3
- package/tx/workers/read.js +18 -3
- package/tx/workers/subsumes.js +18 -2
- package/tx/workers/translate.js +192 -21
- package/tx/workers/validate.js +70 -187
- package/tx/workers/worker.js +12 -35
- package/tx/xversion/xv-conceptmap.js +17 -8
- package/utilities/submit-test-report.js +150 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Tagging an IG source repository and publishing a GitHub release for it, at the end of a
|
|
2
|
+
// successful publication run.
|
|
3
|
+
//
|
|
4
|
+
// Authentication is a GitHub App rather than a personal access token. The reason is scope: the
|
|
5
|
+
// publisher only ever publishes from the HL7 and FHIR organisations, and an App installed on
|
|
6
|
+
// exactly those two - with Contents: Read and write, which covers both tags and releases - can be
|
|
7
|
+
// revoked, audited and attributed on its own. A fine-grained PAT cannot span two organisations
|
|
8
|
+
// (each is limited to a single resource owner), and a classic PAT would act as whoever minted it.
|
|
9
|
+
//
|
|
10
|
+
// The GitHub CLI is deliberately not used. gh cannot authenticate as an App - it only accepts
|
|
11
|
+
// access tokens - so an installation token has to be minted here regardless, and once that code
|
|
12
|
+
// exists the three remaining REST calls are less machinery than requiring gh to be installed and
|
|
13
|
+
// kept current on the server. Nothing in this file needs the database, which also keeps it
|
|
14
|
+
// testable without sqlite3.
|
|
15
|
+
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const axios = require('axios');
|
|
19
|
+
|
|
20
|
+
const API_ROOT = 'https://api.github.com';
|
|
21
|
+
const API_VERSION = '2022-11-28';
|
|
22
|
+
|
|
23
|
+
// GitHub rejects an App JWT whose exp is more than 10 minutes out, measured against ITS clock, so
|
|
24
|
+
// a token minted right on the 10 minute mark fails whenever this server's clock runs slow. Backdate
|
|
25
|
+
// iat by a minute against skew the other way, and take 8 minutes rather than the full 10 - the
|
|
26
|
+
// token is spent within seconds of being minted, so the headroom costs nothing.
|
|
27
|
+
const JWT_BACKDATE_SECONDS = 60;
|
|
28
|
+
const JWT_LIFETIME_SECONDS = 480;
|
|
29
|
+
|
|
30
|
+
function isConfigured(cfg) {
|
|
31
|
+
return !!(cfg && cfg['app-id'] && cfg['private-key'] && cfg.installations);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// task.github_org carries whatever case the requester typed, and GitHub org names are not
|
|
35
|
+
// case-sensitive, so the config's spelling should not have to match it.
|
|
36
|
+
function installationIdFor(cfg, org) {
|
|
37
|
+
const map = (cfg && cfg.installations) || {};
|
|
38
|
+
const wanted = String(org || '').toLowerCase();
|
|
39
|
+
const key = Object.keys(map).find((k) => k.toLowerCase() === wanted);
|
|
40
|
+
return key ? map[key] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function base64url(input) {
|
|
44
|
+
return Buffer.from(input).toString('base64')
|
|
45
|
+
.replace(/\+/g, '-')
|
|
46
|
+
.replace(/\//g, '_')
|
|
47
|
+
.replace(/=+$/, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function appJwt(appId, privateKeyPem, nowSeconds) {
|
|
51
|
+
const now = nowSeconds || Math.floor(Date.now() / 1000);
|
|
52
|
+
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
53
|
+
const payload = base64url(JSON.stringify({
|
|
54
|
+
iat: now - JWT_BACKDATE_SECONDS,
|
|
55
|
+
exp: now + JWT_LIFETIME_SECONDS,
|
|
56
|
+
iss: String(appId)
|
|
57
|
+
}));
|
|
58
|
+
const signer = crypto.createSign('RSA-SHA256');
|
|
59
|
+
signer.update(header + '.' + payload);
|
|
60
|
+
signer.end();
|
|
61
|
+
return header + '.' + payload + '.' + base64url(signer.sign(privateKeyPem));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function apiHeaders(authorization) {
|
|
65
|
+
return {
|
|
66
|
+
'Authorization': authorization,
|
|
67
|
+
'Accept': 'application/vnd.github+json',
|
|
68
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
69
|
+
'User-Agent': 'FHIRsmith-publisher'
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function installationToken(cfg, org, http) {
|
|
74
|
+
const installationId = installationIdFor(cfg, org);
|
|
75
|
+
if (!installationId) {
|
|
76
|
+
throw new Error('No GitHub App installation is configured for the organisation ' + org);
|
|
77
|
+
}
|
|
78
|
+
const pem = fs.readFileSync(cfg['private-key'], 'utf8');
|
|
79
|
+
const jwt = appJwt(cfg['app-id'], pem);
|
|
80
|
+
const res = await http.post(
|
|
81
|
+
API_ROOT + '/app/installations/' + installationId + '/access_tokens',
|
|
82
|
+
{},
|
|
83
|
+
{ headers: apiHeaders('Bearer ' + jwt) });
|
|
84
|
+
if (!res.data || !res.data.token) {
|
|
85
|
+
throw new Error('GitHub returned no installation token for organisation ' + org);
|
|
86
|
+
}
|
|
87
|
+
return res.data.token;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Create the tag if it is not already there. A re-publication of the same version finds it
|
|
91
|
+
// present: the tag is left alone (it is a public ref others may have resolved), but if it points
|
|
92
|
+
// somewhere other than what was just published, say so - a release attached to a commit that is
|
|
93
|
+
// not the published one is exactly the sort of thing nobody notices for a year.
|
|
94
|
+
async function ensureTag(repoUrl, tag, sha, headers, http, log) {
|
|
95
|
+
const existing = await http.get(repoUrl + '/git/ref/' + encodeURIComponent('tags/' + tag),
|
|
96
|
+
{ headers, validateStatus: (s) => s === 200 || s === 404 });
|
|
97
|
+
if (existing.status === 200) {
|
|
98
|
+
const existingSha = existing.data && existing.data.object && existing.data.object.sha;
|
|
99
|
+
if (existingSha && existingSha !== sha) {
|
|
100
|
+
log('warn', 'Tag ' + tag + ' already exists and points at ' + existingSha.substring(0, 8) +
|
|
101
|
+
', not the commit just published (' + sha.substring(0, 8) + '). Leaving the tag as it is.');
|
|
102
|
+
} else {
|
|
103
|
+
log('info', 'Tag ' + tag + ' already exists and is correct - leaving it alone');
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
await http.post(repoUrl + '/git/refs', { ref: 'refs/tags/' + tag, sha: sha }, { headers });
|
|
108
|
+
log('info', 'Created tag ' + tag + ' at ' + sha.substring(0, 8));
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A re-publication should end up with the current build's zip attached, so an existing release for
|
|
113
|
+
// this tag is removed and rebuilt. The tag itself survives that - deleting a release does not
|
|
114
|
+
// delete its ref.
|
|
115
|
+
async function replaceExistingRelease(repoUrl, tag, headers, http, log) {
|
|
116
|
+
const existing = await http.get(repoUrl + '/releases/tags/' + encodeURIComponent(tag),
|
|
117
|
+
{ headers, validateStatus: (s) => s === 200 || s === 404 });
|
|
118
|
+
if (existing.status !== 200) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
await http.delete(repoUrl + '/releases/' + existing.data.id, { headers });
|
|
122
|
+
log('info', 'Removed the previous release for ' + tag + ' so it can be replaced');
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function uploadAsset(uploadUrlTemplate, assetName, zipPath, headers, http) {
|
|
127
|
+
// upload_url comes back as an RFC 6570 template, e.g. https://…/assets{?name,label}
|
|
128
|
+
const base = uploadUrlTemplate.replace(/\{[^}]*\}$/, '');
|
|
129
|
+
const url = base + '?name=' + encodeURIComponent(assetName);
|
|
130
|
+
const body = fs.readFileSync(zipPath);
|
|
131
|
+
const res = await http.post(url, body, {
|
|
132
|
+
headers: Object.assign({}, headers, {
|
|
133
|
+
'Content-Type': 'application/zip',
|
|
134
|
+
'Content-Length': body.length
|
|
135
|
+
}),
|
|
136
|
+
maxBodyLength: Infinity,
|
|
137
|
+
maxContentLength: Infinity
|
|
138
|
+
});
|
|
139
|
+
return res.data;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Tag the source repository and publish a release carrying the source zip.
|
|
144
|
+
*
|
|
145
|
+
* Resolves with a summary; throws on failure. Callers are expected to treat a failure as a
|
|
146
|
+
* warning: by the time this runs the IG itself is already published.
|
|
147
|
+
*/
|
|
148
|
+
async function publishRelease(opts) {
|
|
149
|
+
const {
|
|
150
|
+
config, org, repo, tag, sha, releaseName, body, zipPath, assetName
|
|
151
|
+
} = opts;
|
|
152
|
+
const http = opts.http || axios;
|
|
153
|
+
const log = opts.log || function () {};
|
|
154
|
+
|
|
155
|
+
if (!isConfigured(config)) {
|
|
156
|
+
throw new Error('GitHub App is not configured');
|
|
157
|
+
}
|
|
158
|
+
if (!fs.existsSync(zipPath)) {
|
|
159
|
+
throw new Error('Source zip not found at ' + zipPath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const token = await installationToken(config, org, http);
|
|
163
|
+
const headers = apiHeaders('Bearer ' + token);
|
|
164
|
+
const repoUrl = API_ROOT + '/repos/' + org + '/' + repo;
|
|
165
|
+
|
|
166
|
+
const tagCreated = await ensureTag(repoUrl, tag, sha, headers, http, log);
|
|
167
|
+
const releaseReplaced = await replaceExistingRelease(repoUrl, tag, headers, http, log);
|
|
168
|
+
|
|
169
|
+
const created = await http.post(repoUrl + '/releases', {
|
|
170
|
+
tag_name: tag,
|
|
171
|
+
name: releaseName,
|
|
172
|
+
body: body || '',
|
|
173
|
+
draft: false,
|
|
174
|
+
prerelease: false
|
|
175
|
+
}, { headers });
|
|
176
|
+
|
|
177
|
+
await uploadAsset(created.data.upload_url, assetName, zipPath, headers, http);
|
|
178
|
+
|
|
179
|
+
log('info', (releaseReplaced ? 'Replaced' : 'Created') + ' release ' + tag + ' on ' + org + '/' +
|
|
180
|
+
repo + ' with ' + assetName + ' attached: ' + created.data.html_url);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
tagCreated: tagCreated,
|
|
184
|
+
releaseReplaced: releaseReplaced,
|
|
185
|
+
url: created.data.html_url
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
isConfigured,
|
|
191
|
+
installationIdFor,
|
|
192
|
+
appJwt,
|
|
193
|
+
installationToken,
|
|
194
|
+
publishRelease
|
|
195
|
+
};
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
<a href="/publisher" style="color: gold">Publisher Home</a> |
|
|
92
92
|
<a href="/publisher/tasks" style="color: gold">Tasks</a> |
|
|
93
93
|
<form method="[%loginAction%]" action="/publisher/[%loginPath%]" style="display:inline">
|
|
94
|
+
<input type="hidden" name="_csrf" value="[%csrfToken%]"/>
|
|
94
95
|
<button type="submit" style="background:none; border:none; color:gold; cursor:pointer; font:inherit; padding:0">[%loginTitle%]</button>
|
|
95
96
|
</form>
|
|
96
97
|
</div> <!-- /container -->
|