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
@@ -0,0 +1,557 @@
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
+ // Serves a module's hand-maintained OpenAPI description: loads the YAML file, sets
8
+ // info.version from package.json, and renders it as an HTML reference page. Used by the
9
+ // package server (packages/openapi.yaml) and the terminology registry (registry/openapi.yaml);
10
+ // each module has its own, independent spec.
11
+
12
+ const fs = require('fs');
13
+ const YAML = require('yaml');
14
+ const escape = require('escape-html');
15
+ const commonmark = require('commonmark');
16
+ const packageJson = require('../package.json');
17
+
18
+ // patterns longer than this are collapsed
19
+ const PATTERN_INLINE = 40;
20
+
21
+ const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
22
+
23
+ function commonmarkHtml(text) {
24
+ const reader = new commonmark.Parser();
25
+ const writer = new commonmark.HtmlRenderer({ safe: true });
26
+ return writer.render(reader.parse(text));
27
+ }
28
+
29
+ // GitHub-style pipe tables, which CommonMark doesn't have (and the terminology server's
30
+ // operations use, for their parameters): a header row, a delimiter row, and the rows
31
+ const TABLE_ROW = /^\s*\|.*\|\s*$/;
32
+ const TABLE_DELIMITER = /^\s*\|(\s*:?-+:?\s*\|)+\s*$/;
33
+
34
+ function tableCells(line) {
35
+ const cells = [];
36
+ let cell = '';
37
+ const s = line.trim().replace(/^\|/, '').replace(/\|$/, '');
38
+ for (let i = 0; i < s.length; i++) {
39
+ if (s[i] === '\\' && s[i + 1] === '\\') {
40
+ // an escaped backslash, left for CommonMark (so \\| is a backslash, then the next cell)
41
+ cell += '\\\\';
42
+ i++;
43
+ } else if (s[i] === '\\' && s[i + 1] === '|') {
44
+ cell += '|';
45
+ i++;
46
+ } else if (s[i] === '|') {
47
+ cells.push(cell.trim());
48
+ cell = '';
49
+ } else {
50
+ cell += s[i];
51
+ }
52
+ }
53
+ cells.push(cell.trim());
54
+ return cells;
55
+ }
56
+
57
+ function tableCellHtml(text) {
58
+ return commonmarkHtml(text).trim().replace(/^<p>([\s\S]*)<\/p>$/, '$1');
59
+ }
60
+
61
+ function tableHtml(header, rows) {
62
+ let html = '<table class="table table-condensed"><thead><tr>';
63
+ html += header.map(h => `<th>${tableCellHtml(h)}</th>`).join('');
64
+ html += '</tr></thead><tbody>';
65
+ for (const row of rows) {
66
+ html += '<tr>' + header.map((h, i) => `<td>${tableCellHtml(row[i] || '')}</td>`).join('') + '</tr>';
67
+ }
68
+ return html + '</tbody></table>';
69
+ }
70
+
71
+ function markdown(text) {
72
+ if (!text) {
73
+ return '';
74
+ }
75
+ const lines = text.split('\n');
76
+ const out = [];
77
+ let buf = [];
78
+ const flush = () => {
79
+ if (buf.length > 0) {
80
+ out.push(commonmarkHtml(buf.join('\n')));
81
+ buf = [];
82
+ }
83
+ };
84
+ for (let i = 0; i < lines.length; i++) {
85
+ if (TABLE_ROW.test(lines[i]) && i + 1 < lines.length && TABLE_DELIMITER.test(lines[i + 1])) {
86
+ flush();
87
+ const header = tableCells(lines[i]);
88
+ const rows = [];
89
+ i += 2;
90
+ while (i < lines.length && TABLE_ROW.test(lines[i])) {
91
+ rows.push(tableCells(lines[i]));
92
+ i++;
93
+ }
94
+ i--;
95
+ out.push(tableHtml(header, rows));
96
+ } else {
97
+ buf.push(lines[i]);
98
+ }
99
+ }
100
+ flush();
101
+ return out.join('');
102
+ }
103
+
104
+ // Resolves a local '#/components/...' reference.
105
+ function resolveRef(spec, obj) {
106
+ if (!obj || !obj.$ref) {
107
+ return obj;
108
+ }
109
+ let target = spec;
110
+ for (const part of obj.$ref.replace(/^#\//, '').split('/')) {
111
+ target = target ? target[part] : undefined;
112
+ }
113
+ return target;
114
+ }
115
+
116
+ function refName(ref) {
117
+ return ref.substring(ref.lastIndexOf('/') + 1);
118
+ }
119
+
120
+ function schemaLink(name) {
121
+ return `<a href="#schema-${escape(name)}"><code>${escape(name)}</code></a>`;
122
+ }
123
+
124
+ // A one-line description of a schema: a link for a reference, otherwise its type and the
125
+ // constraints a client needs to know.
126
+ function describeSchema(schema) {
127
+ if (!schema) {
128
+ return '';
129
+ }
130
+ if (schema.$ref) {
131
+ return schemaLink(refName(schema.$ref));
132
+ }
133
+ if (schema.allOf) {
134
+ return schema.allOf.map(describeSchema).join(' + ');
135
+ }
136
+ if (schema.oneOf || schema.anyOf) {
137
+ return (schema.oneOf || schema.anyOf).map(describeSchema).join(' or ');
138
+ }
139
+ if (schema.const !== undefined) {
140
+ return `<code>${escape(JSON.stringify(schema.const))}</code>`;
141
+ }
142
+ if (schema.type === 'array') {
143
+ return `array of ${describeSchema(schema.items)}`;
144
+ }
145
+ if (schema.type === 'object' && schema.additionalProperties && !schema.properties) {
146
+ return `map of ${describeSchema(schema.additionalProperties)}`;
147
+ }
148
+ const parts = [`<code>${escape(schema.type || 'any')}</code>`];
149
+ if (schema['x-fhir-type']) {
150
+ parts.push(`(FHIR <code>${escape(schema['x-fhir-type'])}</code>)`);
151
+ }
152
+ if (schema.format) {
153
+ parts.push(`(${escape(schema.format)})`);
154
+ }
155
+ if (schema.enum) {
156
+ parts.push('one of ' + schema.enum.map(v => `<code>${escape(String(v))}</code>`).join(', '));
157
+ } else if (schema.pattern && schema.pattern.length <= PATTERN_INLINE) {
158
+ parts.push(`matching <code>${escape(schema.pattern)}</code>`);
159
+ } else if (schema.pattern) {
160
+ // long regexes (FHIR's dateTime one is over 200 characters) are shown on demand
161
+ parts.push(`<details class="pattern"><summary>pattern</summary><code>${escape(schema.pattern)}</code></details>`);
162
+ }
163
+ if (schema.maxLength) {
164
+ parts.push(`max ${schema.maxLength} chars`);
165
+ }
166
+ if (schema.default !== undefined) {
167
+ parts.push(`default <code>${escape(String(schema.default))}</code>`);
168
+ }
169
+ return parts.join(' ');
170
+ }
171
+
172
+ function renderParameters(spec, parameters) {
173
+ if (!parameters || parameters.length === 0) {
174
+ return '';
175
+ }
176
+ let html = '<h5>Parameters</h5><table class="table table-condensed"><thead><tr>' +
177
+ '<th>Name</th><th>In</th><th>Schema</th><th>Description</th></tr></thead><tbody>';
178
+ for (const p of parameters.map(p => resolveRef(spec, p))) {
179
+ html += '<tr>';
180
+ html += `<td><code>${escape(p.name)}</code>${p.required ? ' <span class="label label-default">required</span>' : ''}</td>`;
181
+ html += `<td>${escape(p.in)}</td>`;
182
+ html += `<td>${describeSchema(p.schema)}</td>`;
183
+ html += `<td>${markdown(p.description)}</td>`;
184
+ html += '</tr>';
185
+ }
186
+ return html + '</tbody></table>';
187
+ }
188
+
189
+ function renderResponses(spec, responses) {
190
+ let html = '<h5>Responses</h5><table class="table table-condensed"><thead><tr>' +
191
+ '<th>Status</th><th>Description</th><th>Content</th></tr></thead><tbody>';
192
+ for (const [status, r] of Object.entries(responses || {})) {
193
+ const response = resolveRef(spec, r);
194
+ const content = Object.entries(response.content || {})
195
+ .map(([type, media]) => `<code>${escape(type)}</code>: ${describeSchema(media.schema)}`)
196
+ .join('<br/>');
197
+ html += `<tr><td>${escape(status)}</td><td>${markdown(response.description)}</td><td>${content}</td></tr>`;
198
+ }
199
+ return html + '</tbody></table>';
200
+ }
201
+
202
+ function renderSchema(name, schema) {
203
+ let html = `<div class="openapi-schema" id="schema-${escape(name)}"><h4><code>${escape(name)}</code></h4>`;
204
+ html += markdown(schema.description);
205
+ if (schema.properties) {
206
+ const required = new Set(schema.required || []);
207
+ html += '<table class="table table-condensed"><thead><tr><th>Property</th><th>Schema</th><th>Description</th></tr></thead><tbody>';
208
+ for (const [prop, propSchema] of Object.entries(schema.properties)) {
209
+ html += '<tr>';
210
+ html += `<td><code>${escape(prop)}</code>${required.has(prop) ? ' <span class="label label-default">required</span>' : ''}</td>`;
211
+ html += `<td>${describeSchema(propSchema)}</td>`;
212
+ html += `<td>${markdown(propSchema.description)}</td>`;
213
+ html += '</tr>';
214
+ }
215
+ html += '</tbody></table>';
216
+ if (schema.additionalProperties) {
217
+ html += `<p>Other properties: ${describeSchema(schema.additionalProperties)}</p>`;
218
+ }
219
+ } else {
220
+ html += `<p>${describeSchema(schema)}</p>`;
221
+ }
222
+ return html + '</div>';
223
+ }
224
+
225
+ // ---- "try it" ----
226
+ //
227
+ // Each GET operation gets a form built from its documented parameters; Send makes the request
228
+ // with fetch(), asking for the operation's JSON (not HTML - a browser following the link
229
+ // would get the web page), and shows the URL, the status, some headers, the body, and the
230
+ // same request as a curl command. Other methods get an example curl command only: trying a
231
+ // POST from a public page would create real resources.
232
+
233
+ /**
234
+ * The request a try-it form describes. Runs in the browser (it's serialised into the page)
235
+ * and in the tests.
236
+ *
237
+ * @param {string} pathTemplate - e.g. /packages/{id}/{version}
238
+ * @param {Array<{in: string, name: string, value: string}>} values - the form's fields
239
+ * @returns {{url: string, headers: Object, missing: string[]}}
240
+ */
241
+ function buildTryItRequest(pathTemplate, values) {
242
+ const missing = [];
243
+ let url = pathTemplate;
244
+ const query = [];
245
+ const headers = {};
246
+ for (const v of values) {
247
+ const value = (v.value || '').trim();
248
+ if (v.in === 'path') {
249
+ if (!value) {
250
+ missing.push(v.name);
251
+ }
252
+ url = url.split('{' + v.name + '}').join(encodeURIComponent(value));
253
+ } else if (!value) {
254
+ continue;
255
+ } else if (v.in === 'query') {
256
+ query.push(encodeURIComponent(v.name) + '=' + encodeURIComponent(value));
257
+ } else if (v.in === 'header') {
258
+ headers[v.name] = value;
259
+ }
260
+ }
261
+ if (query.length > 0) {
262
+ url += '?' + query.join('&');
263
+ }
264
+ return { url, headers, missing };
265
+ }
266
+
267
+ /** A curl command for a request; quoted for a POSIX shell. */
268
+ function curlCommand(method, url, headers, dataFile) {
269
+ const q = (s) => "'" + String(s).split("'").join("'\\''") + "'";
270
+ let cmd = 'curl';
271
+ if (method !== 'GET') {
272
+ cmd += ' -X ' + method;
273
+ }
274
+ for (const [k, v] of Object.entries(headers)) {
275
+ cmd += ' -H ' + q(k + ': ' + v);
276
+ }
277
+ if (dataFile) {
278
+ cmd += ' --data-binary @' + dataFile;
279
+ }
280
+ return cmd + ' ' + q(url);
281
+ }
282
+
283
+ // The page script: wires up every try-it form, and fills in the absolute URLs of the
284
+ // example curl commands.
285
+ function tryItScript() {
286
+ const MAX_SHOW = 200000;
287
+ document.querySelectorAll('form.try-it').forEach(function (form) {
288
+ form.addEventListener('submit', function (e) {
289
+ e.preventDefault();
290
+ const values = Array.prototype.map.call(form.querySelectorAll('[data-in]'), function (el) {
291
+ return { in: el.dataset.in, name: el.dataset.name, value: el.value };
292
+ });
293
+ const req = buildTryItRequest(form.dataset.path, values);
294
+ const out = form.querySelector('.try-result');
295
+ const show = function (cls, text) {
296
+ out.querySelector(cls).textContent = text;
297
+ };
298
+ out.hidden = false;
299
+ if (req.missing.length > 0) {
300
+ show('.try-status', 'Required: ' + req.missing.join(', '));
301
+ show('.try-url', '');
302
+ show('.try-curl', '');
303
+ show('.try-body', '');
304
+ return;
305
+ }
306
+ const headers = Object.assign({ Accept: form.dataset.accept }, req.headers);
307
+ show('.try-url', 'GET ' + req.url);
308
+ show('.try-curl', curlCommand('GET', location.origin + req.url, headers));
309
+ show('.try-status', 'Sending...');
310
+ show('.try-body', '');
311
+ const started = Date.now();
312
+ fetch(req.url, { headers: headers }).then(function (res) {
313
+ const type = res.headers.get('Content-Type') || '';
314
+ let status = res.status + ' ' + res.statusText + ' (' + (Date.now() - started) + 'ms)';
315
+ status += '\nContent-Type: ' + type;
316
+ ['Location', 'Last-Modified', 'Link'].forEach(function (h) {
317
+ if (res.headers.get(h)) {
318
+ status += '\n' + h + ': ' + res.headers.get(h);
319
+ }
320
+ });
321
+ show('.try-status', status);
322
+ if (/json|text|xml|yaml/.test(type)) {
323
+ return res.text().then(function (text) {
324
+ let body = text;
325
+ if (/json/.test(type)) {
326
+ try {
327
+ body = JSON.stringify(JSON.parse(text), null, 2);
328
+ } catch (err) {
329
+ // show it as it came
330
+ }
331
+ }
332
+ if (body.length > MAX_SHOW) {
333
+ body = body.substring(0, MAX_SHOW) + '\n... (' + body.length + ' characters; the rest is not shown)';
334
+ }
335
+ show('.try-body', body);
336
+ });
337
+ }
338
+ return res.blob().then(function (b) {
339
+ show('.try-body', '(' + b.size + ' bytes of ' + (type || 'unknown content') + ')');
340
+ });
341
+ }).catch(function (err) {
342
+ show('.try-status', 'The request failed: ' + err.message);
343
+ });
344
+ });
345
+ });
346
+ document.querySelectorAll('.try-example').forEach(function (el) {
347
+ el.textContent = curlCommand(el.dataset.method, location.origin + el.dataset.path,
348
+ JSON.parse(el.dataset.headers), el.dataset.file || null);
349
+ });
350
+ }
351
+
352
+ // The content type a try-it request asks for: the success response's first non-HTML one.
353
+ function tryItAccept(op) {
354
+ const ok = Object.keys(op.responses || {}).find(s => /^2/.test(s));
355
+ const types = ok ? Object.keys((op.responses[ok] && op.responses[ok].content) || {}) : [];
356
+ return types.find(t => t !== 'text/html') || 'application/json';
357
+ }
358
+
359
+ function renderTryIt(spec, method, fullPath, item, op) {
360
+ const params = [...(item.parameters || []), ...(op.parameters || [])].map(p => resolveRef(spec, p));
361
+ if (method !== 'get') {
362
+ // an example only
363
+ const headers = {};
364
+ let file = null;
365
+ const content = op.requestBody && resolveRef(spec, op.requestBody).content;
366
+ if (content) {
367
+ headers['Content-Type'] = Object.keys(content)[0];
368
+ file = 'body.json';
369
+ }
370
+ if ((op.security || []).some(s => Object.keys(s).length > 0)) {
371
+ headers.Authorization = 'Bearer {token}';
372
+ }
373
+ return '<h5>Example</h5><pre class="try-example" data-method="' + method.toUpperCase() +
374
+ '" data-path="' + escape(fullPath) + '" data-headers="' + escape(JSON.stringify(headers)) + '"' +
375
+ (file ? ' data-file="' + file + '"' : '') + '></pre>';
376
+ }
377
+ // closed until wanted: the summary is the button that opens it
378
+ let html = '<details class="try-it-panel"><summary class="btn btn-default btn-sm try-btn">Try it</summary>';
379
+ html += `<form class="try-it" data-path="${escape(fullPath)}" data-accept="${escape(tryItAccept(op))}">`;
380
+ if (params.length > 0) {
381
+ html += '<table class="table table-condensed"><tbody>';
382
+ for (const p of params) {
383
+ const id = `try-${escape(op.operationId || '')}-${escape(p.in)}-${escape(p.name)}`;
384
+ const attrs = `id="${id}" data-in="${escape(p.in)}" data-name="${escape(p.name)}" class="form-control input-sm"`;
385
+ html += `<tr><td style="width: 30%"><label for="${id}"><code>${escape(p.name)}</code>` +
386
+ `${p.required ? ' <span class="label label-default">required</span>' : ''}` +
387
+ `${p.in !== 'query' ? ` <small>(${escape(p.in)})</small>` : ''}</label></td><td>`;
388
+ const schema = resolveRef(spec, p.schema) || {};
389
+ if (Array.isArray(schema.enum)) {
390
+ html += `<select ${attrs}><option value=""></option>` +
391
+ schema.enum.map(v => `<option>${escape(String(v))}</option>`).join('') + '</select>';
392
+ } else {
393
+ const example = p.example !== undefined ? String(p.example) : '';
394
+ // a required path parameter starts with its example, so Send works straight away
395
+ const value = p.in === 'path' && example ? ` value="${escape(example)}"` : '';
396
+ html += `<input type="text" ${attrs}${value} placeholder="${escape(example)}"/>`;
397
+ }
398
+ html += '</td></tr>';
399
+ }
400
+ html += '</tbody></table>';
401
+ }
402
+ html += '<button type="submit" class="btn btn-primary btn-sm">Send</button>';
403
+ html += '<div class="try-result" hidden>' +
404
+ '<pre class="try-url"></pre><pre class="try-curl"></pre>' +
405
+ '<pre class="try-status"></pre><pre class="try-body"></pre></div>';
406
+ return html + '</form></details>';
407
+ }
408
+
409
+ // The page's own styles. The site's stylesheet is Bootstrap 3, so these use its classes
410
+ // (panel, table-condensed, label, input-sm, btn-default) and fill in the rest.
411
+ const PAGE_STYLE = `<style>
412
+ .openapi-ref td, .openapi-ref th { vertical-align: top; }
413
+ .openapi-ref td code, .openapi-ref td { overflow-wrap: anywhere; }
414
+ .openapi-ref pre { white-space: pre-wrap; overflow-wrap: anywhere; }
415
+ .openapi-ref hr { border-top: 2px solid #ccc; margin: 24px 0; }
416
+ .openapi-ref .openapi-schema { margin-bottom: 24px; }
417
+ .openapi-ref details.pattern { display: inline; }
418
+ .openapi-ref details.pattern > summary { display: inline; cursor: pointer; color: #555; text-decoration: underline dotted; }
419
+ .openapi-ref details.pattern[open] > code { display: block; margin-top: 4px; }
420
+ .openapi-ref summary.try-btn { list-style: none; display: inline-block; margin: 4px 0 8px; font-weight: bold;
421
+ color: #333; background-color: #fff; border: 1px solid #adadad; box-shadow: 0 1px 2px rgba(0,0,0,0.15); }
422
+ .openapi-ref summary.try-btn:hover { background-color: #ebebeb; color: #333; }
423
+ .openapi-ref summary.try-btn::-webkit-details-marker { display: none; }
424
+ .openapi-ref summary.try-btn::before { content: '\\25B6\\00a0'; font-size: 80%; }
425
+ .openapi-ref details[open] > summary.try-btn::before { content: '\\25BC\\00a0'; }
426
+ .openapi-ref details[open] > summary.try-btn { background-color: #e6e6e6; color: #333; box-shadow: inset 0 2px 3px rgba(0,0,0,0.15); }
427
+ .openapi-ref form.try-it { border: 1px solid #ddd; border-radius: 4px; padding: 8px 12px; margin-bottom: 8px; background: #fafafa; }
428
+ .openapi-ref .try-result { margin-top: 8px; }
429
+ .openapi-ref .try-body { max-height: 30em; overflow: auto; }
430
+ </style>`;
431
+
432
+ function buildHtml(spec, BASE_PATH) {
433
+ let html = PAGE_STYLE + '<div class="openapi-ref">';
434
+ html += `<h1>${escape(spec.info.title)} API</h1>`;
435
+ html += `<p class="lead">${escape(spec.info.summary || '')}</p>`;
436
+ html += `<p>Machine-readable description (OpenAPI ${escape(spec.openapi)}): ` +
437
+ `<a href="${BASE_PATH}/openapi.json">openapi.json</a> &middot; ` +
438
+ `<a href="${BASE_PATH}/openapi.yaml">openapi.yaml</a></p>`;
439
+ html += markdown(spec.info.description);
440
+ if (spec.externalDocs) {
441
+ html += `<p>See also: <a href="${escape(spec.externalDocs.url)}">${escape(spec.externalDocs.description || spec.externalDocs.url)}</a></p>`;
442
+ }
443
+
444
+ html += '<h2>Endpoints</h2>';
445
+ // an index, when there are enough of them to need one
446
+ const all = Object.entries(spec.paths).flatMap(([p, item]) => METHODS.filter(m => item[m]).map(m => [p, m, item[m]]));
447
+ if (all.length > 12) {
448
+ html += '<table class="table table-condensed"><tbody>';
449
+ for (const [p, method, op] of all) {
450
+ html += `<tr><td><span class="label label-default">${method.toUpperCase()}</span></td>` +
451
+ `<td><a href="#${escape(op.operationId || '')}"><code>${escape(BASE_PATH + p)}</code></a></td><td>${escape(op.summary || '')}</td></tr>`;
452
+ }
453
+ html += '</tbody></table>';
454
+ }
455
+ let first = true;
456
+ for (const [p, item] of Object.entries(spec.paths)) {
457
+ for (const method of METHODS) {
458
+ const op = item[method];
459
+ if (!op) {
460
+ continue;
461
+ }
462
+ if (!first) {
463
+ html += '<hr/>';
464
+ }
465
+ first = false;
466
+ html += `<div class="panel panel-default" id="${escape(op.operationId || '')}"><div class="panel-heading">`;
467
+ html += `<span class="label label-primary">${method.toUpperCase()}</span> <code>${escape(BASE_PATH + p)}</code> &mdash; ${escape(op.summary || '')}`;
468
+ html += '</div><div class="panel-body">';
469
+ html += markdown(op.description);
470
+ html += renderParameters(spec, [...(item.parameters || []), ...(op.parameters || [])]);
471
+ html += renderResponses(spec, op.responses);
472
+ html += renderTryIt(spec, method, BASE_PATH + p, item, op);
473
+ html += '</div></div>';
474
+ }
475
+ }
476
+
477
+ html += '<hr/><h2>Schemas</h2>';
478
+ for (const [name, schema] of Object.entries((spec.components && spec.components.schemas) || {})) {
479
+ html += renderSchema(name, schema);
480
+ }
481
+ html += '</div>';
482
+ html += '<script>\n' + buildTryItRequest.toString() + '\n' + curlCommand.toString() + '\n(' +
483
+ tryItScript.toString() + ')();\n</script>';
484
+ return html;
485
+ }
486
+
487
+ /**
488
+ * @param {string} specPath - the YAML file
489
+ * @param {string} basePath - where the module is mounted (e.g. '/packages'); used for links
490
+ * and to show full paths in the HTML reference
491
+ * @param {Object} [options]
492
+ * @param {string} [options.schemasPath] - a generated schemas file (see
493
+ * utilities/generate-openapi-schemas.js) whose schemas are merged into components.schemas.
494
+ * A schema in the YAML of the same name wins.
495
+ * @param {Function} [options.build] - adds to the spec once it's loaded (paths made from
496
+ * data, say). The YAML served is then the whole spec, not the file
497
+ * @param {string} [options.serverUrl] - the server url, when the module is mounted in more
498
+ * than one place (the YAML's servers are replaced)
499
+ */
500
+ function createOpenApiDoc(specPath, basePath, options = {}) {
501
+ let cachedYaml = null;
502
+ let cachedSpec = null;
503
+ let cachedHtml = null;
504
+ let cachedJson = null;
505
+
506
+ function getYaml() {
507
+ if (cachedYaml === null) {
508
+ cachedYaml = options.build || options.serverUrl
509
+ ? YAML.stringify(getSpec(), { lineWidth: 0 })
510
+ : fs.readFileSync(specPath, 'utf8');
511
+ }
512
+ return cachedYaml;
513
+ }
514
+
515
+ // The parsed spec, with info.version set to this server's version. Callers get a copy, so
516
+ // nothing can modify the cached one.
517
+ function getSpec() {
518
+ if (cachedSpec === null) {
519
+ const spec = YAML.parse(fs.readFileSync(specPath, 'utf8'));
520
+ spec.info.version = packageJson.version;
521
+ if (options.serverUrl) {
522
+ spec.servers = [{ url: options.serverUrl }];
523
+ }
524
+ if (options.schemasPath) {
525
+ const generated = JSON.parse(fs.readFileSync(options.schemasPath, 'utf8'));
526
+ spec.components = spec.components || {};
527
+ spec.components.schemas = { ...generated.schemas, ...(spec.components.schemas || {}) };
528
+ }
529
+ if (options.build) {
530
+ options.build(spec);
531
+ }
532
+ cachedSpec = spec;
533
+ }
534
+ return structuredClone(cachedSpec);
535
+ }
536
+
537
+ // The spec as JSON text, for serving: made once, rather than a copy cloned and serialised
538
+ // for every request
539
+ function getJson() {
540
+ if (cachedJson === null) {
541
+ cachedJson = JSON.stringify(getSpec());
542
+ }
543
+ return cachedJson;
544
+ }
545
+
546
+ // The body of the HTML reference page (to be wrapped in the module's page template).
547
+ function renderHtml() {
548
+ if (cachedHtml === null) {
549
+ cachedHtml = buildHtml(getSpec(), basePath);
550
+ }
551
+ return cachedHtml;
552
+ }
553
+
554
+ return { getSpec, getJson, getYaml, renderHtml, SPEC_PATH: specPath, BASE_PATH: basePath };
555
+ }
556
+
557
+ module.exports = { createOpenApiDoc, buildTryItRequest, curlCommand, markdown };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Refuse form posts that come from another site (CSRF).
3
+ *
4
+ * The session-cookie modules (publisher, testing) take state-changing actions on a POST
5
+ * from a logged-in browser. A page on another site can make the browser send such a
6
+ * POST, cookie and all, unless something stops it. Two things do here:
7
+ *
8
+ * - the session cookie is SameSite=Lax, so current browsers leave it off a POST that
9
+ * starts on another site;
10
+ * - this check: every browser sends an Origin header on a cross-site POST (and on
11
+ * same-site ones), so a POST whose Origin names another host is refused, whatever
12
+ * the browser did with the cookie.
13
+ *
14
+ * A request with no Origin at all (curl, older browsers on same-origin posts) is let
15
+ * through - it can't be a browser being driven by another site's page. Behind a proxy,
16
+ * the proxy must pass on Host or X-Forwarded-Host, or every post is refused.
17
+ *
18
+ * @module library/same-origin
19
+ */
20
+
21
+ const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
22
+
23
+ /**
24
+ * Is this request allowed by the same-origin rule?
25
+ * @param {import('express').Request} req
26
+ * @returns {boolean}
27
+ */
28
+ function isSameOrigin(req) {
29
+ if (SAFE.has(req.method)) {
30
+ return true;
31
+ }
32
+ const origin = req.get('Origin');
33
+ if (!origin) {
34
+ return true;
35
+ }
36
+ let host;
37
+ try {
38
+ // "null" (a sandboxed page, a file: URL, some redirects) does not parse, and is refused
39
+ host = new URL(origin).host;
40
+ } catch {
41
+ return false;
42
+ }
43
+ return host === req.get('host') || host === req.get('x-forwarded-host');
44
+ }
45
+
46
+ /**
47
+ * Middleware that refuses cross-site requests that change things.
48
+ * @param {function(req, res): void} [reject] - sends the refusal; default a plain 403
49
+ */
50
+ function requireSameOrigin(reject) {
51
+ return (req, res, next) => {
52
+ if (isSameOrigin(req)) {
53
+ return next();
54
+ }
55
+ if (reject) {
56
+ return reject(req, res);
57
+ }
58
+ res.status(403).type('text/plain').send('Cross-site form posts are not accepted');
59
+ };
60
+ }
61
+
62
+ module.exports = { isSameOrigin, requireSameOrigin };
@@ -60,6 +60,27 @@ const Utilities = {
60
60
  }
61
61
 
62
62
  return parts.join(' ');
63
+ },
64
+
65
+ /**
66
+ * How long ago a time was, for people: "35 minutes ago", "3 hours ago", "2 days ago"
67
+ * @param {Date|string|number} time - the earlier time
68
+ * @param {number} now - Date.now(), for testing
69
+ * @returns {string}
70
+ */
71
+ describeAgo(time, now = Date.now()) {
72
+ const secs = Math.max(0, Math.floor((now - new Date(time).getTime()) / 1000));
73
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'} ago`;
74
+ if (secs < 60) {
75
+ return 'just now';
76
+ }
77
+ if (secs < 3600) {
78
+ return plural(Math.floor(secs / 60), 'minute');
79
+ }
80
+ if (secs < 172800) {
81
+ return plural(Math.floor(secs / 3600), 'hour');
82
+ }
83
+ return plural(Math.floor(secs / 86400), 'day');
63
84
  }
64
85
 
65
86
  };