From 7169e6fb4ed0ffecc00a062fd3ad939f528ab5d0 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 5 Oct 2026 18:03:29 +1300 Subject: [PATCH 01/16] openapi infrastructure --- library/fhir-openapi-schema.js | 374 +++++++++++++++++++++++++++ library/html-server.js | 5 +- library/openapi-doc.js | 453 +++++++++++++++++++++++++++++++++ package-lock.json | 96 ++++++- package.json | 1 + 5 files changed, 917 insertions(+), 12 deletions(-) create mode 100644 library/fhir-openapi-schema.js create mode 100644 library/openapi-doc.js diff --git a/library/fhir-openapi-schema.js b/library/fhir-openapi-schema.js new file mode 100644 index 00000000..c0cb9ea2 --- /dev/null +++ b/library/fhir-openapi-schema.js @@ -0,0 +1,374 @@ +// +// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au) +// +// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause +// + +/** + * Generates OpenAPI 3.1 (JSON Schema 2020-12) component schemas for FHIR resources from + * their StructureDefinitions. + * + * The published FHIR JSON schema doesn't work well in an OpenAPI description: every + * resource reaches every other one (through contained resources and Bundle entries) and + * every datatype (through extensions), so referencing one resource drags in the whole + * specification. This generator produces only what the named resources actually use, and + * cuts the recursion at two fixed boundaries: + * + * - an element whose type is a resource (Bundle.entry.resource, and so on) is "any + * resource": an object with a resourceType, otherwise not described. A module can + * narrow it with an overlay (a search Bundle of TestReports, say) + * - an extension is described as an object with a url and a value[x], but the value isn't + * described further + * + * Elements can be prohibited outright (the testing module doesn't accept contained + * resources), and an overlay can tighten the generated schemas to what an endpoint really + * requires. + * + * What's generated: + * - one schema per resource and complex datatype, named for the type, and one per backbone + * element, named for its path (TestReport_Setup_Action); contentReference becomes a $ref + * - every object is closed (additionalProperties: false), as FHIR JSON is + * - a primitive element `x` gets its value (with the type's regex as a pattern, and the + * codes of a required binding as an enum where they can be worked out from the package) + * and its `_x` sibling for the element's id and extensions. In a repeating primitive, + * either array may have nulls where the other has an entry + * - choice elements (value[x]) are expanded to their concrete names (valueString ...) + * - required: elements with min >= 1, and resourceType + * + * @module library/fhir-openapi-schema + */ + +const fs = require('fs'); +const path = require('path'); + +const REGEX_EXT = 'http://hl7.org/fhir/StructureDefinition/regex'; +const SYSTEM_STRING = 'http://hl7.org/fhirpath/System.String'; + +/** The boundary schemas; always included. */ +const BOUNDARY_SCHEMAS = { + Extension: { + type: 'object', + description: 'An extension. The value (value[x]) can be any FHIR datatype, and is not described further here.', + properties: { + id: { type: 'string' }, + url: { type: 'string', description: 'The extension\'s definition' }, + extension: { type: 'array', items: { $ref: '#/components/schemas/Extension' } } + }, + patternProperties: { + '^_?value[A-Z][A-Za-z0-9]*$': { description: 'The value (value[x]), and its _value[x] sibling for a primitive' } + }, + required: ['url'], + additionalProperties: false + }, + AnyResource: { + type: 'object', + description: 'A FHIR resource of any type. Not described further here.', + properties: { + resourceType: { type: 'string' } + }, + required: ['resourceType'], + additionalProperties: true + } +}; + +function ref(name) { + return { $ref: `#/components/schemas/${name}` }; +} + +function cap(s) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function schemaNameForPath(p) { + return p.split('.').map(cap).join('_'); +} + +function extValue(type, url) { + const e = (type.extension || []).find(x => x.url === url); + return e ? (e.valueUrl || e.valueString || e.valueUri) : undefined; +} + +/** + * The StructureDefinitions, CodeSystems and ValueSets in an unpacked FHIR package. + */ +class FhirPackage { + constructor(dir) { + this.dir = dir; + this.sds = new Map(); + this.byUrl = new Map(); + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith('.json') || !/^(StructureDefinition|CodeSystem|ValueSet)-/.test(f)) { + continue; + } + const r = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); + if (r.url) { + this.byUrl.set(r.url, r); + } + if (r.resourceType === 'StructureDefinition' && r.derivation === 'specialization') { + this.sds.set(r.type, r); + } + } + const pj = path.join(dir, 'package.json'); + this.id = fs.existsSync(pj) ? (() => { + const p = JSON.parse(fs.readFileSync(pj, 'utf8')); + return `${p.name}#${p.version}`; + })() : dir; + } + + sd(type) { + return this.sds.get(type); + } + + /** The codes of a value set, if they can be worked out simply; otherwise null. */ + codes(vsUrl) { + const vs = this.byUrl.get(vsUrl.split('|')[0]); + if (!vs || !vs.compose || vs.compose.exclude || !Array.isArray(vs.compose.include)) { + return null; + } + const codes = []; + for (const inc of vs.compose.include) { + if (inc.filter || inc.valueSet || !inc.system) { + return null; + } + if (inc.concept) { + codes.push(...inc.concept.map(c => c.code)); + } else { + const cs = this.byUrl.get(inc.system); + if (!cs || cs.content !== 'complete' || !cs.concept) { + return null; + } + const walk = (list) => list.forEach(c => { + codes.push(c.code); + if (c.concept) { + walk(c.concept); + } + }); + walk(cs.concept); + } + } + return codes.length > 0 ? codes : null; + } +} + +class FhirSchemaGenerator { + /** + * @param {FhirPackage} pkg + * @param {Object} [options] + * @param {string[]} [options.prohibit] - element names that are left out of every resource + * and type (e.g. 'contained'); the schemas are closed, so they're then not allowed + */ + constructor(pkg, options = {}) { + this.pkg = pkg; + this.prohibit = new Set(options.prohibit || []); + this.schemas = {}; + this.queue = []; + this.queued = new Set(); + } + + /** + * @param {string[]} roots - the resources (and types) to describe + * @returns {Object} name -> schema + */ + generate(roots) { + Object.assign(this.schemas, structuredClone(BOUNDARY_SCHEMAS)); + roots.forEach(r => this.enqueue(r)); + while (this.queue.length > 0) { + this.generateType(this.queue.shift()); + } + // a stable order, so the generated file diffs cleanly + const sorted = {}; + for (const k of Object.keys(this.schemas).sort()) { + sorted[k] = this.schemas[k]; + } + return sorted; + } + + enqueue(type) { + if (!this.queued.has(type) && !BOUNDARY_SCHEMAS[type]) { + this.queued.add(type); + this.queue.push(type); + } + } + + generateType(type) { + const sd = this.pkg.sd(type); + if (!sd) { + throw new Error(`No StructureDefinition for ${type} in ${this.pkg.id}`); + } + if (sd.kind === 'primitive-type') { + throw new Error(`${type} is a primitive type, and has no schema of its own`); + } + this.elements = sd.snapshot.element; + this.buildObject(type, type, sd.kind === 'resource', sd.description); + } + + children(p) { + const prefix = p + '.'; + return this.elements.filter(e => e.path.startsWith(prefix) && !e.path.substring(prefix.length).includes('.')); + } + + buildObject(schemaName, p, isResource, description) { + const schema = { type: 'object' }; + if (description) { + schema.description = description; + } + const properties = {}; + const required = []; + if (isResource) { + properties.resourceType = { const: p }; + required.push('resourceType'); + } + for (const el of this.children(p)) { + const name = el.path.substring(p.length + 1); + if (el.max === '0' || this.prohibit.has(name)) { + continue; + } + if (name.endsWith('[x]')) { + const base = name.slice(0, -3); + for (const t of el.type) { + this.addProperty(properties, base + cap(t.code), el, t); + } + } else { + this.addProperty(properties, name, el, el.type ? el.type[0] : null); + if (el.min >= 1) { + required.push(name); + } + } + } + schema.properties = properties; + if (required.length > 0) { + schema.required = required; + } + schema.additionalProperties = false; + this.schemas[schemaName] = schema; + } + + addProperty(properties, name, el, type) { + const many = el.max !== '1'; + const description = el.short; + let value; + let primitive = false; + + if (el.contentReference) { + value = ref(schemaNameForPath(el.contentReference.replace(/^.*#/, ''))); + } else if (!type) { + throw new Error(`${el.path} has no type`); + } else if (type.code === SYSTEM_STRING) { + // element ids, and the like + value = { type: 'string' }; + } else if (type.code === 'BackboneElement' || (type.code === 'Element' && this.children(el.path).length > 0)) { + const sn = schemaNameForPath(el.path); + this.buildObject(sn, el.path, false, el.definition); + value = ref(sn); + } else if (type.code === 'Extension') { + value = ref('Extension'); + } else { + const tsd = this.pkg.sd(type.code); + if (!tsd) { + throw new Error(`${el.path}: no StructureDefinition for type ${type.code}`); + } + if (tsd.kind === 'resource' || type.code === 'Resource' || type.code === 'DomainResource') { + value = ref('AnyResource'); + } else if (tsd.kind === 'primitive-type') { + primitive = true; + value = this.primitive(tsd, el); + } else { + this.enqueue(type.code); + value = ref(type.code); + } + } + + if (primitive) { + // the value, and the _x sibling for its id and extensions + if (many) { + properties[name] = { type: 'array', description, items: { anyOf: [value, { type: 'null' }] } }; + properties['_' + name] = { type: 'array', items: { anyOf: [ref('Element'), { type: 'null' }] } }; + } else { + properties[name] = { ...value, description }; + properties['_' + name] = ref('Element'); + } + this.enqueue('Element'); + } else if (many) { + properties[name] = { type: 'array', description, items: value }; + } else { + properties[name] = { ...value, description }; + } + } + + primitive(tsd, el) { + const valueEl = tsd.snapshot.element.find(e => e.path === `${tsd.type}.value`); + const vt = valueEl && valueEl.type ? valueEl.type[0] : {}; + // x-fhir-type records the FHIR type, which the JSON type doesn't say (dateTime, code, uri + // are all strings); the reference page shows it instead of the regex + const schema = { 'x-fhir-type': tsd.type }; + switch (tsd.type) { + case 'boolean': schema.type = 'boolean'; break; + case 'integer': schema.type = 'integer'; break; + case 'positiveInt': schema.type = 'integer'; schema.minimum = 1; break; + case 'unsignedInt': schema.type = 'integer'; schema.minimum = 0; break; + case 'decimal': schema.type = 'number'; break; + default: schema.type = 'string'; + } + if (schema.type === 'string') { + const regex = extValue(vt, REGEX_EXT); + if (regex) { + schema.pattern = regex.startsWith('^') ? regex : `^(${regex})$`; + } + } + if (tsd.type === 'code' && el.binding && el.binding.strength === 'required' && el.binding.valueSet) { + const codes = this.pkg.codes(el.binding.valueSet); + if (codes) { + schema.enum = codes; + delete schema.pattern; + } + } + return schema; + } +} + +/** + * Applies an overlay to generated schemas: objects merge, `required` lists are unioned, + * and anything else in the overlay replaces what was generated. A schema in the overlay + * that wasn't generated is added as is. + */ +function applyOverlay(schemas, overlay) { + const merge = (target, src) => { + for (const [k, v] of Object.entries(src)) { + if (k === 'required' && Array.isArray(target.required)) { + target.required = [...new Set([...target.required, ...v])]; + } else if (v && typeof v === 'object' && !Array.isArray(v) && target[k] && typeof target[k] === 'object' && !Array.isArray(target[k]) && !v.$replace) { + merge(target[k], v); + } else { + const value = v && v.$replace ? { ...v } : v; + if (value && value.$replace) { + delete value.$replace; + } + target[k] = value; + } + } + }; + for (const [name, s] of Object.entries(overlay || {})) { + if (schemas[name]) { + merge(schemas[name], s); + } else { + schemas[name] = s; + } + } + return schemas; +} + +/** + * Generates the schemas a module's config asks for. + * + * @param {Object} config - { package, roots, prohibit, overlay } + * @param {string} packageDir - the unpacked package + * @returns {{generatedFrom: string, schemas: Object}} + */ +function generateSchemas(config, packageDir) { + const pkg = new FhirPackage(packageDir); + const gen = new FhirSchemaGenerator(pkg, { prohibit: config.prohibit }); + const schemas = applyOverlay(gen.generate(config.roots), structuredClone(config.overlay || {})); + return { generatedFrom: pkg.id, schemas }; +} + +module.exports = { FhirPackage, FhirSchemaGenerator, applyOverlay, generateSchemas, BOUNDARY_SCHEMAS }; diff --git a/library/html-server.js b/library/html-server.js index 3ab60de3..33aafe5e 100644 --- a/library/html-server.js +++ b/library/html-server.js @@ -8,6 +8,7 @@ const fs = require('fs'); const path = require('path'); const escape = require('escape-html'); +const packageJson = require('../package.json'); let sponsorMessage = ''; @@ -60,7 +61,6 @@ class HtmlServer { // Default options const renderOptions = { - version: '4.0.1', downloadDate: 'Unknown', totalResources: 0, totalPackages: 0, @@ -72,7 +72,8 @@ class HtmlServer { let html = template .replace(/\[%title%\]/g, escape(title)) .replace(/\[%content%\]/g, content) // Content is assumed to be already-safe HTML - .replace(/\[%ver%\]/g, escape(renderOptions.version)) + // [%ver%] is the FHIRsmith version in every template (it follows the FHIRsmith link) + .replace(/\[%ver%\]/g, escape(packageJson.version)) .replace(/\[%download-date%\]/g, escape(renderOptions.downloadDate)) .replace(/\[%total-resources%\]/g, escape(renderOptions.totalResources.toLocaleString())) .replace(/\[%total-packages%\]/g, escape(renderOptions.totalPackages.toLocaleString())) diff --git a/library/openapi-doc.js b/library/openapi-doc.js new file mode 100644 index 00000000..3391408a --- /dev/null +++ b/library/openapi-doc.js @@ -0,0 +1,453 @@ +// +// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au) +// +// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause +// + +// Serves a module's hand-maintained OpenAPI description: loads the YAML file, sets +// info.version from package.json, and renders it as an HTML reference page. Used by the +// package server (packages/openapi.yaml) and the terminology registry (registry/openapi.yaml); +// each module has its own, independent spec. + +const fs = require('fs'); +const YAML = require('yaml'); +const escape = require('escape-html'); +const commonmark = require('commonmark'); +const packageJson = require('../package.json'); + +// patterns longer than this are collapsed +const PATTERN_INLINE = 40; + +const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +function markdown(text) { + if (!text) { + return ''; + } + const reader = new commonmark.Parser(); + const writer = new commonmark.HtmlRenderer({ safe: true }); + return writer.render(reader.parse(text)); +} + +// Resolves a local '#/components/...' reference. +function resolveRef(spec, obj) { + if (!obj || !obj.$ref) { + return obj; + } + let target = spec; + for (const part of obj.$ref.replace(/^#\//, '').split('/')) { + target = target ? target[part] : undefined; + } + return target; +} + +function refName(ref) { + return ref.substring(ref.lastIndexOf('/') + 1); +} + +function schemaLink(name) { + return `${escape(name)}`; +} + +// A one-line description of a schema: a link for a reference, otherwise its type and the +// constraints a client needs to know. +function describeSchema(schema) { + if (!schema) { + return ''; + } + if (schema.$ref) { + return schemaLink(refName(schema.$ref)); + } + if (schema.allOf) { + return schema.allOf.map(describeSchema).join(' + '); + } + if (schema.oneOf || schema.anyOf) { + return (schema.oneOf || schema.anyOf).map(describeSchema).join(' or '); + } + if (schema.const !== undefined) { + return `${escape(JSON.stringify(schema.const))}`; + } + if (schema.type === 'array') { + return `array of ${describeSchema(schema.items)}`; + } + if (schema.type === 'object' && schema.additionalProperties && !schema.properties) { + return `map of ${describeSchema(schema.additionalProperties)}`; + } + const parts = [`${escape(schema.type || 'any')}`]; + if (schema['x-fhir-type']) { + parts.push(`(FHIR ${escape(schema['x-fhir-type'])})`); + } + if (schema.format) { + parts.push(`(${escape(schema.format)})`); + } + if (schema.enum) { + parts.push('one of ' + schema.enum.map(v => `${escape(String(v))}`).join(', ')); + } else if (schema.pattern && schema.pattern.length <= PATTERN_INLINE) { + parts.push(`matching ${escape(schema.pattern)}`); + } else if (schema.pattern) { + // long regexes (FHIR's dateTime one is over 200 characters) are shown on demand + parts.push(`
pattern${escape(schema.pattern)}
`); + } + if (schema.maxLength) { + parts.push(`max ${schema.maxLength} chars`); + } + if (schema.default !== undefined) { + parts.push(`default ${escape(String(schema.default))}`); + } + return parts.join(' '); +} + +function renderParameters(spec, parameters) { + if (!parameters || parameters.length === 0) { + return ''; + } + let html = '
Parameters
' + + ''; + for (const p of parameters.map(p => resolveRef(spec, p))) { + html += ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + } + return html + '
NameInSchemaDescription
${escape(p.name)}${p.required ? ' required' : ''}${escape(p.in)}${describeSchema(p.schema)}${markdown(p.description)}
'; +} + +function renderResponses(spec, responses) { + let html = '
Responses
' + + ''; + for (const [status, r] of Object.entries(responses || {})) { + const response = resolveRef(spec, r); + const content = Object.entries(response.content || {}) + .map(([type, media]) => `${escape(type)}: ${describeSchema(media.schema)}`) + .join('
'); + html += ``; + } + return html + '
StatusDescriptionContent
${escape(status)}${markdown(response.description)}${content}
'; +} + +function renderSchema(name, schema) { + let html = `

${escape(name)}

`; + html += markdown(schema.description); + if (schema.properties) { + const required = new Set(schema.required || []); + html += ''; + for (const [prop, propSchema] of Object.entries(schema.properties)) { + html += ''; + html += ``; + html += ``; + html += ``; + html += ''; + } + html += '
PropertySchemaDescription
${escape(prop)}${required.has(prop) ? ' required' : ''}${describeSchema(propSchema)}${markdown(propSchema.description)}
'; + if (schema.additionalProperties) { + html += `

Other properties: ${describeSchema(schema.additionalProperties)}

`; + } + } else { + html += `

${describeSchema(schema)}

`; + } + return html + '
'; +} + +// ---- "try it" ---- +// +// Each GET operation gets a form built from its documented parameters; Send makes the request +// with fetch(), asking for the operation's JSON (not HTML - a browser following the link +// would get the web page), and shows the URL, the status, some headers, the body, and the +// same request as a curl command. Other methods get an example curl command only: trying a +// POST from a public page would create real resources. + +/** + * The request a try-it form describes. Runs in the browser (it's serialised into the page) + * and in the tests. + * + * @param {string} pathTemplate - e.g. /packages/{id}/{version} + * @param {Array<{in: string, name: string, value: string}>} values - the form's fields + * @returns {{url: string, headers: Object, missing: string[]}} + */ +function buildTryItRequest(pathTemplate, values) { + const missing = []; + let url = pathTemplate; + const query = []; + const headers = {}; + for (const v of values) { + const value = (v.value || '').trim(); + if (v.in === 'path') { + if (!value) { + missing.push(v.name); + } + url = url.split('{' + v.name + '}').join(encodeURIComponent(value)); + } else if (!value) { + continue; + } else if (v.in === 'query') { + query.push(encodeURIComponent(v.name) + '=' + encodeURIComponent(value)); + } else if (v.in === 'header') { + headers[v.name] = value; + } + } + if (query.length > 0) { + url += '?' + query.join('&'); + } + return { url, headers, missing }; +} + +/** A curl command for a request; quoted for a POSIX shell. */ +function curlCommand(method, url, headers, dataFile) { + const q = (s) => "'" + String(s).split("'").join("'\\''") + "'"; + let cmd = 'curl'; + if (method !== 'GET') { + cmd += ' -X ' + method; + } + for (const [k, v] of Object.entries(headers)) { + cmd += ' -H ' + q(k + ': ' + v); + } + if (dataFile) { + cmd += ' --data-binary @' + dataFile; + } + return cmd + ' ' + q(url); +} + +// The page script: wires up every try-it form, and fills in the absolute URLs of the +// example curl commands. +function tryItScript() { + const MAX_SHOW = 200000; + document.querySelectorAll('form.try-it').forEach(function (form) { + form.addEventListener('submit', function (e) { + e.preventDefault(); + const values = Array.prototype.map.call(form.querySelectorAll('[data-in]'), function (el) { + return { in: el.dataset.in, name: el.dataset.name, value: el.value }; + }); + const req = buildTryItRequest(form.dataset.path, values); + const out = form.querySelector('.try-result'); + const show = function (cls, text) { + out.querySelector(cls).textContent = text; + }; + out.hidden = false; + if (req.missing.length > 0) { + show('.try-status', 'Required: ' + req.missing.join(', ')); + show('.try-url', ''); + show('.try-curl', ''); + show('.try-body', ''); + return; + } + const headers = Object.assign({ Accept: form.dataset.accept }, req.headers); + show('.try-url', 'GET ' + req.url); + show('.try-curl', curlCommand('GET', location.origin + req.url, headers)); + show('.try-status', 'Sending...'); + show('.try-body', ''); + const started = Date.now(); + fetch(req.url, { headers: headers }).then(function (res) { + const type = res.headers.get('Content-Type') || ''; + let status = res.status + ' ' + res.statusText + ' (' + (Date.now() - started) + 'ms)'; + status += '\nContent-Type: ' + type; + ['Location', 'Last-Modified', 'Link'].forEach(function (h) { + if (res.headers.get(h)) { + status += '\n' + h + ': ' + res.headers.get(h); + } + }); + show('.try-status', status); + if (/json|text|xml|yaml/.test(type)) { + return res.text().then(function (text) { + let body = text; + if (/json/.test(type)) { + try { + body = JSON.stringify(JSON.parse(text), null, 2); + } catch (err) { + // show it as it came + } + } + if (body.length > MAX_SHOW) { + body = body.substring(0, MAX_SHOW) + '\n... (' + body.length + ' characters; the rest is not shown)'; + } + show('.try-body', body); + }); + } + return res.blob().then(function (b) { + show('.try-body', '(' + b.size + ' bytes of ' + (type || 'unknown content') + ')'); + }); + }).catch(function (err) { + show('.try-status', 'The request failed: ' + err.message); + }); + }); + }); + document.querySelectorAll('.try-example').forEach(function (el) { + el.textContent = curlCommand(el.dataset.method, location.origin + el.dataset.path, + JSON.parse(el.dataset.headers), el.dataset.file || null); + }); +} + +// The content type a try-it request asks for: the success response's first non-HTML one. +function tryItAccept(op) { + const ok = Object.keys(op.responses || {}).find(s => /^2/.test(s)); + const types = ok ? Object.keys((op.responses[ok] && op.responses[ok].content) || {}) : []; + return types.find(t => t !== 'text/html') || 'application/json'; +} + +function renderTryIt(spec, method, fullPath, item, op) { + const params = [...(item.parameters || []), ...(op.parameters || [])].map(p => resolveRef(spec, p)); + if (method !== 'get') { + // an example only + const headers = {}; + let file = null; + const content = op.requestBody && resolveRef(spec, op.requestBody).content; + if (content) { + headers['Content-Type'] = Object.keys(content)[0]; + file = 'body.json'; + } + if ((op.security || []).some(s => Object.keys(s).length > 0)) { + headers.Authorization = 'Bearer {token}'; + } + return '
Example
';
+  }
+  // closed until wanted: the summary is the button that opens it
+  let html = '
Try it'; + html += `
`; + if (params.length > 0) { + html += ''; + for (const p of params) { + const id = `try-${escape(op.operationId || '')}-${escape(p.in)}-${escape(p.name)}`; + const attrs = `id="${id}" data-in="${escape(p.in)}" data-name="${escape(p.name)}" class="form-control input-sm"`; + html += `'; + } + html += '
`; + const schema = resolveRef(spec, p.schema) || {}; + if (Array.isArray(schema.enum)) { + html += `'; + } else { + const example = p.example !== undefined ? String(p.example) : ''; + // a required path parameter starts with its example, so Send works straight away + const value = p.in === 'path' && example ? ` value="${escape(example)}"` : ''; + html += ``; + } + html += '
'; + } + html += ''; + html += ''; + return html + '
'; +} + +// The page's own styles. The site's stylesheet is Bootstrap 3, so these use its classes +// (panel, table-condensed, label, input-sm, btn-default) and fill in the rest. +const PAGE_STYLE = ``; + +function buildHtml(spec, BASE_PATH) { + let html = PAGE_STYLE + '
'; + html += `

${escape(spec.info.title)} API

`; + html += `

${escape(spec.info.summary || '')}

`; + html += `

Machine-readable description (OpenAPI ${escape(spec.openapi)}): ` + + `openapi.json · ` + + `openapi.yaml

`; + html += markdown(spec.info.description); + if (spec.externalDocs) { + html += `

See also: ${escape(spec.externalDocs.description || spec.externalDocs.url)}

`; + } + + html += '

Endpoints

'; + let first = true; + for (const [p, item] of Object.entries(spec.paths)) { + for (const method of METHODS) { + const op = item[method]; + if (!op) { + continue; + } + if (!first) { + html += '
'; + } + first = false; + html += `
`; + html += `${method.toUpperCase()} ${escape(BASE_PATH + p)} — ${escape(op.summary || '')}`; + html += '
'; + html += markdown(op.description); + html += renderParameters(spec, [...(item.parameters || []), ...(op.parameters || [])]); + html += renderResponses(spec, op.responses); + html += renderTryIt(spec, method, BASE_PATH + p, item, op); + html += '
'; + } + } + + html += '

Schemas

'; + for (const [name, schema] of Object.entries((spec.components && spec.components.schemas) || {})) { + html += renderSchema(name, schema); + } + html += '
'; + html += ''; + return html; +} + +/** + * @param {string} specPath - the YAML file + * @param {string} basePath - where the module is mounted (e.g. '/packages'); used for links + * and to show full paths in the HTML reference + * @param {Object} [options] + * @param {string} [options.schemasPath] - a generated schemas file (see + * utilities/generate-openapi-schemas.js) whose schemas are merged into components.schemas. + * A schema in the YAML of the same name wins. + */ +function createOpenApiDoc(specPath, basePath, options = {}) { + let cachedYaml = null; + let cachedSpec = null; + let cachedHtml = null; + + function getYaml() { + if (cachedYaml === null) { + cachedYaml = fs.readFileSync(specPath, 'utf8'); + } + return cachedYaml; + } + + // The parsed spec, with info.version set to this server's version. Callers get a copy, so + // nothing can modify the cached one. + function getSpec() { + if (cachedSpec === null) { + const spec = YAML.parse(getYaml()); + spec.info.version = packageJson.version; + if (options.schemasPath) { + const generated = JSON.parse(fs.readFileSync(options.schemasPath, 'utf8')); + spec.components = spec.components || {}; + spec.components.schemas = { ...generated.schemas, ...(spec.components.schemas || {}) }; + } + cachedSpec = spec; + } + return structuredClone(cachedSpec); + } + + // The body of the HTML reference page (to be wrapped in the module's page template). + function renderHtml() { + if (cachedHtml === null) { + cachedHtml = buildHtml(getSpec(), basePath); + } + return cachedHtml; + } + + return { getSpec, getYaml, renderHtml, SPEC_PATH: specPath, BASE_PATH: basePath }; +} + +module.exports = { createOpenApiDoc, buildTryItRequest, curlCommand }; diff --git a/package-lock.json b/package-lock.json index b6d97d28..77e38df8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "@types/jest": "^29.5.8", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", + "ajv": "^8.20.0", "eslint": "^8.57.1", "eslint-plugin-promise": "^7.2.1", "jest": "^29.7.0", @@ -660,6 +661,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -670,6 +688,13 @@ "node": ">= 4" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2110,16 +2135,16 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3839,6 +3864,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3849,6 +3891,13 @@ "node": ">= 4" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4138,6 +4187,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", @@ -6041,9 +6107,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -8150,6 +8216,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", diff --git a/package.json b/package.json index 4ab1bdbb..640a8b63 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@types/jest": "^29.5.8", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", + "ajv": "^8.20.0", "eslint": "^8.57.1", "eslint-plugin-promise": "^7.2.1", "jest": "^29.7.0", From 949e5be72a9df5bcd01dd6dcd7cc079a04359324 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 5 Oct 2026 18:03:52 +1300 Subject: [PATCH 02/16] set up openAPI for packages server --- packages/openapi.js | 13 + packages/openapi.yaml | 603 ++++++++++++++++++++++++++++++++ packages/packages-template.html | 4 +- packages/packages.js | 333 ++++++++++++++---- packages/readme.md | 120 +++++++ 5 files changed, 997 insertions(+), 76 deletions(-) create mode 100644 packages/openapi.js create mode 100644 packages/openapi.yaml create mode 100644 packages/readme.md diff --git a/packages/openapi.js b/packages/openapi.js new file mode 100644 index 00000000..debfaf2e --- /dev/null +++ b/packages/openapi.js @@ -0,0 +1,13 @@ +// +// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au) +// +// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause +// + +// The package server's OpenAPI description. openapi.yaml is the source of truth; see the +// comment at its top. + +const path = require('path'); +const { createOpenApiDoc } = require('../library/openapi-doc'); + +module.exports = createOpenApiDoc(path.join(__dirname, 'openapi.yaml'), '/packages'); diff --git a/packages/openapi.yaml b/packages/openapi.yaml new file mode 100644 index 00000000..58d6d0fb --- /dev/null +++ b/packages/openapi.yaml @@ -0,0 +1,603 @@ +# OpenAPI description of the FHIRsmith package server (/packages). +# +# This file is the contract for the public, read-only package API. It is maintained by +# hand, and tests/packages/openapi.test.js keeps it honest: every route the router +# registers must be described here or explicitly excluded, and the query parameter +# names, lengths and patterns must match the validation rules in packages.js. +# +# Administrative endpoints (crawl, update-package) and operational pages (log, stats, +# status) are deliberately not described. info.version is filled in from package.json +# when the spec is served. + +openapi: 3.1.0 +info: + title: FHIRsmith Package Server + version: "0.0.0" + summary: Search and download FHIR NPM packages + description: | + A registry of FHIR packages, built by crawling the package feeds listed in the + FHIR package registry. It implements the parts of the npm registry protocol that + FHIR tooling uses (package documents and tarball downloads), plus FHIR-specific + search by canonical URL, FHIR version and dependency. + + **Content negotiation.** Most endpoints return JSON. If the request's `Accept` + header contains `text/html`, they return a browsable HTML page instead. The JSON + form is the contract; the HTML form is for people and may change at any time. + + **Query parameters are strict.** An unknown parameter, a repeated parameter, a + value that is too long, or one that does not match the documented pattern is + rejected with a 400 `Error` response rather than being ignored. + license: + name: BSD-3-Clause + url: https://opensource.org/license/bsd-3-clause +externalDocs: + description: FHIR NPM package specification + url: https://hl7.org/fhir/packages.html +servers: + - url: /packages +tags: + - name: search + description: Find packages + - name: registry + description: npm-compatible package documents and downloads + - name: reports + description: Registry-wide reports + +paths: + /catalog: + get: + tags: [search] + operationId: searchCatalog + summary: Search packages + description: | + Returns the packages matching all of the supplied criteria. With no criteria, + returns every package. + + Unless `name` contains a `#version`, each package appears once, described by + its current version, and `count` gives its total download count. When `name` + contains `#`, every matching version is returned and `count` is omitted. + + Results are ordered by publication date unless `sort` is given. + parameters: + - $ref: '#/components/parameters/name' + - $ref: '#/components/parameters/dependson' + - $ref: '#/components/parameters/pkgcanonical' + - $ref: '#/components/parameters/canonical' + - $ref: '#/components/parameters/fhirversion' + - $ref: '#/components/parameters/dependency' + - $ref: '#/components/parameters/sort' + - $ref: '#/components/parameters/objWrapper' + - $ref: '#/components/parameters/prerelease' + responses: + '200': + description: The matching packages + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/PackageSummary' } + - $ref: '#/components/schemas/WrappedSearchResult' + text/html: + schema: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '500': { $ref: '#/components/responses/ServerError' } + + /-/v1/search: + get: + tags: [search] + operationId: searchV1 + summary: Search packages (npm registry search API) + description: | + The npm registry search API, so `npm search --registry` works against this + server. It takes npm's `text`, `size` and `from` parameters as well as the FHIR + search parameters of `/catalog`, and returns npm's result shape. + + Differences from the npm registry: + + * `text` terms match the package id or description; every term must match. + npm qualifiers (`keywords:`, `author:`, `scope:`, `not:`, `is:` ...) are + ignored. + * There is no relevance ranking: `quality`, `popularity` and `maintenance` are + accepted and ignored, every `score` is 1, and results are in publication + order unless `sort` is given. + * Without `size`, every result is returned (npm defaults to 20, but npm's own + clients always send `size`). + * `keywords` and `maintainers` are always empty, and there is no `publisher`. + parameters: + - name: text + in: query + description: Space-separated search terms. + schema: + type: string + maxLength: 200 + pattern: '^[a-zA-Z0-9._:@/#| -]*$' + example: ips + - name: size + in: query + description: Maximum number of results to return (at most 250). + schema: + type: string + maxLength: 3 + pattern: '^\d{1,3}$' + example: '20' + - name: from + in: query + description: Offset of the first result to return. + schema: + type: string + default: '0' + maxLength: 6 + pattern: '^\d{1,6}$' + - name: quality + in: query + description: npm ranking weight. Ignored. + schema: { type: string, maxLength: 10, pattern: '^(\d+(\.\d+)?)?$' } + - name: popularity + in: query + description: npm ranking weight. Ignored. + schema: { type: string, maxLength: 10, pattern: '^(\d+(\.\d+)?)?$' } + - name: maintenance + in: query + description: npm ranking weight. Ignored. + schema: { type: string, maxLength: 10, pattern: '^(\d+(\.\d+)?)?$' } + - $ref: '#/components/parameters/name' + - $ref: '#/components/parameters/dependson' + - $ref: '#/components/parameters/pkgcanonical' + - $ref: '#/components/parameters/canonical' + - $ref: '#/components/parameters/fhirversion' + - $ref: '#/components/parameters/dependency' + - $ref: '#/components/parameters/sort' + - $ref: '#/components/parameters/objWrapper' + - $ref: '#/components/parameters/prerelease' + responses: + '200': + description: The matching packages + content: + application/json: + schema: { $ref: '#/components/schemas/NpmSearchResult' } + text/html: + schema: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '500': { $ref: '#/components/responses/ServerError' } + + /updates: + get: + tags: [search] + operationId: listUpdates + summary: Package versions published since a date + description: | + Lists every package version published on or after a date, most recent first. + The date is either relative (`daysValue` days ago, the default) or absolute + (`dateValue`). + parameters: + - name: dateType + in: query + description: Whether the cut-off is relative to today or an absolute date. + schema: + type: string + enum: [relative, absolute] + default: relative + maxLength: 10 + pattern: '^(relative|absolute)?$' + - name: daysValue + in: query + description: With `dateType=relative`, how many days back to go. + schema: + type: string + default: '10' + maxLength: 3 + pattern: '^\d{1,3}$' + - name: dateValue + in: query + description: With `dateType=absolute`, the cut-off date. Defaults to today. + schema: + type: string + format: date + maxLength: 10 + pattern: '^\d{4}-\d{2}-\d{2}$' + responses: + '200': + description: The package versions published since the cut-off + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/PackageUpdate' } + text/html: + schema: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '500': { $ref: '#/components/responses/ServerError' } + + /broken: + get: + tags: [reports] + operationId: listBrokenDependencies + summary: Package versions with unresolvable dependencies + description: | + Reports every package version that declares a dependency on a package + version this registry does not hold. Dependencies are matched on package id + and major.minor version, so a dependency on `1.1.0` is satisfied by `1.1.2`. + parameters: + - name: filter + in: query + description: Only report package versions whose `id#version` contains this text. + schema: + type: string + maxLength: 100 + pattern: '^[a-zA-Z0-9._-]*$' + responses: + '200': + description: The broken dependencies, keyed by the package version that declares them + content: + application/json: + schema: { $ref: '#/components/schemas/BrokenDependencies' } + text/html: + schema: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '500': { $ref: '#/components/responses/ServerError' } + + /{id}: + get: + tags: [registry] + operationId: getPackage + summary: Package document (npm registry format) + description: | + The npm registry document for a package: every version this registry holds, + with its metadata, dependencies and tarball URL. `dist-tags.latest` is the most + recently published version. + parameters: + - name: id + in: path + required: true + description: The package id, e.g. `hl7.fhir.r4.core`. + schema: { type: string } + example: hl7.fhir.uv.ips + responses: + '200': + description: The package document + content: + application/json: + schema: { $ref: '#/components/schemas/PackageDocument' } + text/html: + schema: { type: string } + '404': + description: No package with this id is known + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '500': { $ref: '#/components/responses/ServerError' } + + /{id}/{version}: + get: + tags: [registry] + operationId: downloadPackage + summary: Download a package tarball + description: | + Returns the package tarball (`.tgz`). + + If no version matches exactly, the most recently published pre-release of that + version (`{version}-*`) is returned instead, so `1.0.0` will find `1.0.0-ballot` + when there is no `1.0.0`. + + If the server is configured to serve packages from bucket storage, this + redirects to the tarball there. + parameters: + - name: id + in: path + required: true + description: | + The package id. A scoped id (`@scope/name`) must have its `/` encoded as `%2F`. + schema: + type: string + maxLength: 100 + pattern: '^(@[a-z0-9._-]+\/)?[a-zA-Z0-9._-]+$' + example: hl7.fhir.uv.ips + - name: version + in: path + required: true + description: The package version. + schema: + type: string + maxLength: 50 + pattern: '^[a-zA-Z0-9._-]+$' + example: 1.1.0 + responses: + '200': + description: The package tarball + headers: + Content-Disposition: + description: '`attachment; filename="{id}#{version}.tgz"`' + schema: { type: string } + content: + application/tar+gzip: + schema: + type: string + contentEncoding: binary + '302': + description: Redirect to the tarball in bucket storage + headers: + Location: + schema: { type: string, format: uri } + '400': { $ref: '#/components/responses/BadRequest' } + '404': + description: No such package version is known + content: + text/plain: + schema: { type: string } + '500': { $ref: '#/components/responses/ServerError' } + +components: + parameters: + name: + name: name + in: query + description: | + Packages whose id contains this text. Append `#version` to search specific + versions (every version containing that text is then returned, not just the + current one). Matched with SQL `LIKE`, so `_` matches any single character. + schema: + type: string + maxLength: 100 + pattern: '^[a-zA-Z0-9._#-]*$' + example: hl7.fhir.uv.ips + dependson: + name: dependson + in: query + description: | + Packages with a dependency whose `id@version` contains this text. `#` or `|` + may be used in place of `@`. + schema: + type: string + maxLength: 100 + pattern: '^[a-zA-Z0-9._#|@-]*$' + pkgcanonical: + name: pkgcanonical + in: query + description: | + The package's canonical URL. Matched exactly, or as a prefix if the value ends + with `%`. + schema: + type: string + maxLength: 200 + pattern: '^[a-zA-Z0-9._:/-]*%?$' + example: http://hl7.org/fhir/uv/ips + canonical: + name: canonical + in: query + description: Packages containing a resource whose canonical URL starts with this value. + schema: + type: string + maxLength: 200 + pattern: '^[a-zA-Z0-9._:/-]*$' + example: http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips + fhirversion: + name: fhirversion + in: query + description: | + Packages for this FHIR release: R2 (1.0.x), R2B (1.4.x), R3 (3.0.x), R4 (4.0.x), + R4B (4.3.x), R5 (5.0.x) or R6 (6.0.x, including ballots). + schema: + type: string + maxLength: 10 + pattern: '^(R2|R2B|R3|R4|R4B|R5|R6)?$' + enum: [R2, R2B, R3, R4, R4B, R5, R6] + dependency: + name: dependency + in: query + description: | + Packages that depend on this package. Either `id` (any version), or + `id#version`, `id|version` or `id@version`; a version is matched as a prefix. + schema: + type: string + maxLength: 100 + pattern: '^[a-zA-Z0-9._#|@-]*$' + example: hl7.fhir.r4.core#4.0.1 + sort: + name: sort + in: query + description: | + Sort field; prefix with `-` for descending order. Versions sort semver-style, + with a pre-release (`1.0.0-ballot`) before its release. Without `sort`, results + are in publication order. + schema: + type: string + maxLength: 20 + pattern: '^-?(name|version|date|count|fhirversion|kind|canonical)$' + example: -date + objWrapper: + name: objWrapper + in: query + description: | + `true` wraps the results as `{"objects": [{"package": ...}]}`, the core of the npm + search result shape. Retained for older clients; new clients wanting the npm + shape should use `/-/v1/search`. + schema: + type: string + maxLength: 10 + pattern: '^(true|false)?$' + enum: ['true', 'false'] + prerelease: + name: prerelease + in: query + description: | + Sent by the HL7 Java package client. Accepted for compatibility; it has no effect, + since searches already include pre-release versions. + schema: + type: string + maxLength: 5 + pattern: '^(true|false)?$' + enum: ['true', 'false'] + + responses: + BadRequest: + description: A parameter was unknown, repeated, too long or badly formed + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + ServerError: + description: The server failed to process the request + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + schemas: + FhirVersion: + type: string + description: | + The FHIR version(s) the package is for, e.g. `4.0.1`. Packages for more than + one version list them separated by `, `. + example: 4.0.1 + PackageKind: + type: string + enum: [fhir.core, fhir.ig, fhir.template] + + PackageSummary: + type: object + description: A package version, as returned by search. + required: [name, version, fhirVersion, kind, url] + properties: + name: { type: string, description: Package id, example: hl7.fhir.uv.ips } + version: { type: string, example: 1.1.0 } + fhirVersion: { $ref: '#/components/schemas/FhirVersion' } + canonical: { type: string, format: uri, description: The package's canonical URL } + kind: { $ref: '#/components/schemas/PackageKind' } + url: { type: string, format: uri, description: Where to download the tarball } + date: { type: string, format: date-time, description: Publication date } + count: + type: integer + description: Total downloads of the package (all versions). Omitted for versioned searches. + description: { type: string } + + WrappedSearchResult: + type: object + required: [objects] + properties: + objects: + type: array + items: + type: object + required: [package] + properties: + package: { $ref: '#/components/schemas/PackageSummary' } + + NpmSearchResult: + type: object + description: The npm registry search result shape. + required: [objects, total, time] + properties: + objects: + type: array + items: + type: object + required: [package, score, searchScore] + properties: + package: + allOf: + - $ref: '#/components/schemas/PackageSummary' + - type: object + required: [keywords, maintainers] + properties: + keywords: { type: array, items: { type: string }, maxItems: 0 } + maintainers: { type: array, items: { type: object }, maxItems: 0 } + score: + type: object + properties: + final: { type: number } + detail: + type: object + properties: + quality: { type: number } + popularity: { type: number } + maintenance: { type: number } + searchScore: { type: number } + total: { type: integer, description: Number of matches before size/from are applied } + time: { type: string, description: When the search ran } + + PackageUpdate: + type: object + description: A package version published since the requested date. + required: [name, version, date, fhirVersion, kind, url] + properties: + name: { type: string } + version: { type: string } + date: { type: string, format: date-time } + fhirVersion: { $ref: '#/components/schemas/FhirVersion' } + canonical: { type: string, format: uri } + kind: { $ref: '#/components/schemas/PackageKind' } + url: { type: string, format: uri } + description: { type: string } + + PackageDocument: + type: object + description: An npm registry package document. + required: [_id, name, dist-tags, versions] + properties: + _id: { type: string } + name: { type: string } + description: { type: string } + dist-tags: + type: object + required: [latest] + properties: + latest: { type: string, description: The most recently published version } + versions: + type: object + description: Every version this registry holds, keyed by version. + additionalProperties: { $ref: '#/components/schemas/PackageVersion' } + + PackageVersion: + type: object + required: [name, version, date, fhirVersion, kind, count, url, dist] + properties: + name: { type: string } + _id: { type: string, description: '`{id}@{version}`' } + version: { type: string } + date: { type: string, format: date-time } + fhirVersion: { $ref: '#/components/schemas/FhirVersion' } + kind: { $ref: '#/components/schemas/PackageKind' } + count: { type: integer, description: Downloads of this version } + canonical: { type: string, format: uri } + url: { type: string, format: uri } + homepage: { type: string } + license: { type: string } + author: + type: object + properties: + name: { type: string } + description: { type: string } + dependencies: + type: object + description: Package id to version. + additionalProperties: { type: string } + example: { hl7.fhir.r4.core: 4.0.1 } + dist: + type: object + required: [tarball] + properties: + shasum: { type: string } + tarball: { type: string, format: uri } + + BrokenDependencies: + type: object + description: | + Keys are `id#version` of the package versions with broken dependencies; each + value lists the unresolvable dependencies as `id@version`. `date` is when the + report was generated. + required: [date] + properties: + date: { type: string, format: date-time } + additionalProperties: + type: array + items: { type: string } + example: + date: '2026-10-04T00:00:00.000Z' + 'example.fhir.ig#1.0.0': ['hl7.fhir.uv.missing@2.0.0'] + + Error: + type: object + required: [error] + properties: + error: { type: string } + message: { type: string } + parameter: { type: string, description: The offending parameter, for repeated parameters } diff --git a/packages/packages-template.html b/packages/packages-template.html index f6e9fb2a..132577bb 100644 --- a/packages/packages-template.html +++ b/packages/packages-template.html @@ -9,6 +9,7 @@ + @@ -59,7 +60,8 @@ Packages Home  |  Updates  |  Broken  |  - Crawler Log + Crawler Log  |  + API diff --git a/packages/packages.js b/packages/packages.js index 898a39f9..03de383e 100644 --- a/packages/packages.js +++ b/packages/packages.js @@ -18,6 +18,66 @@ const {validateParameter} = require("../library/utilities"); const {describeCron} = require("../library/cron-utilities"); const {tokenMatches, tokenConfigured} = require("../library/request-token"); const pckLog = Logger.getInstance().child({ module: 'packages' }); +const packagesOpenApi = require('./openapi'); + +// Query parameter rules for validateQueryParams. These are also the contract published in +// openapi.yaml, and tests/packages/openapi.test.js checks that the two agree - so change +// both together. +const SEARCH_PARAMS = { + name: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ }, + dependson: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|@-]*$/ }, + pkgcanonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*%?$/ }, + canonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*$/ }, + fhirversion: { maxLength: 10, pattern: /^(R2|R2B|R3|R4|R4B|R5|R6)?$/ }, + dependency: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|@-]*$/ }, + sort: { maxLength: 20, pattern: /^-?(name|version|date|count|fhirversion|kind|canonical)$/ }, + objWrapper: { maxLength: 10, pattern: /^(true|false)?$/ }, + // Sent by the Java PackageClient (org.hl7.fhir.utilities.npm). Accepted, but has no + // effect: a search already returns pre-release versions. + prerelease: { maxLength: 5, pattern: /^(true|false)?$/ } +}; + +// /-/v1/search also takes the npm registry search parameters. npm always sends all of +// these; quality, popularity and maintenance are ranking weights, accepted and ignored. +const V1_SEARCH_PARAMS = { + ...SEARCH_PARAMS, + text: { maxLength: 200, pattern: /^[a-zA-Z0-9._:@/#| -]*$/ }, + size: { maxLength: 3, pattern: /^\d{1,3}$/ }, + from: { maxLength: 6, pattern: /^\d{1,6}$/ }, + quality: { maxLength: 10, pattern: /^(\d+(\.\d+)?)?$/ }, + popularity: { maxLength: 10, pattern: /^(\d+(\.\d+)?)?$/ }, + maintenance: { maxLength: 10, pattern: /^(\d+(\.\d+)?)?$/ } +}; +const V1_SEARCH_MAX_SIZE = 250; + +// FHIR release codes accepted by fhirversion=, mapped to the version prefix they select. +const FHIR_RELEASE_VERSIONS = { + 'R2': '1.0', + 'R2B': '1.4', + 'R3': '3.0', + 'R4': '4.0', + 'R4B': '4.3', + 'R5': '5.0', + 'R6': '6.0' +}; + +// No default for dateValue: a default computed here would be frozen at the date the server +// started. The /updates handler supplies today's date when none is given. +const UPDATES_PARAMS = { + dateType: { maxLength: 10, pattern: /^(relative|absolute)?$/, default: 'relative' }, + daysValue: { maxLength: 3, pattern: /^\d{1,3}$/, default: '10' }, + dateValue: { maxLength: 10, pattern: /^\d{4}-\d{2}-\d{2}$/ } +}; + +const BROKEN_PARAMS = { + filter: { maxLength: 100, pattern: /^[a-zA-Z0-9._-]*$/ } +}; + +// Path parameter rules for GET /:id/:version (also published in openapi.yaml). +const PACKAGE_ID_PATTERN = /^(@[a-z0-9._-]+\/)?[a-zA-Z0-9._-]+$/; +const PACKAGE_ID_MAX_LENGTH = 100; +const PACKAGE_VERSION_PATTERN = /^[a-zA-Z0-9._-]+$/; +const PACKAGE_VERSION_MAX_LENGTH = 50; class PackagesModule { constructor(stats) { @@ -52,6 +112,8 @@ class PackagesModule { "frame-ancestors 'none'" ].join('; ')); res.removeHeader('X-Powered-By'); + // RFC 8631: where to find the machine-readable description of this API + res.setHeader('Link', `<${req.baseUrl}/openapi.json>; rel="service-desc", <${req.baseUrl}/openapi>; rel="service-doc"`); next(); }); } @@ -145,6 +207,10 @@ class PackagesModule { } else if (condition.operator === 'IN_SUBQUERY') { query += ` AND ${condition.column} IN (${condition.subquery})`; params.push(condition.value); + } else if (condition.operator === 'TEXT') { + // A free-text search term: in the package id or its description + query += ' AND (PackageVersions.Id LIKE ? OR CAST(PackageVersions.Description AS TEXT) LIKE ?)'; + params.push(`%${condition.value}%`, `%${condition.value}%`); } }); return { query, params }; @@ -158,6 +224,7 @@ class PackagesModule { canonicalUrl = '', fhirVersion = '', dependency = '', + text = '', sort = '' } = params; @@ -182,13 +249,14 @@ class PackagesModule { // Add the missing dependency search logic if (dependson) { validateParameter(dependson, "dependson", String); - versioned = dependson.includes('#'); - // This requires a subquery to PackageDependencies table + versioned = /[#|@]/.test(dependson); + // Dependencies are stored as id@version (npm convention), so accept # and | as + // the separator too. conditions.push({ column: 'PackageVersions.PackageVersionKey', operator: 'IN_SUBQUERY', subquery: 'SELECT PackageVersionKey FROM PackageDependencies WHERE Dependency LIKE ?', - value: `%${dependson}%` + value: `%${dependson.replace(/[#|]/, '@')}%` }); } @@ -212,7 +280,7 @@ class PackagesModule { // Add FHIR version search (requires PackageFHIRVersions table) if (fhirVersion) { - const mappedVersion = this.getVersion(fhirVersion); + const mappedVersion = FHIR_RELEASE_VERSIONS[fhirVersion] || fhirVersion; conditions.push({ column: 'PackageVersions.PackageVersionKey', operator: 'IN_SUBQUERY', @@ -224,13 +292,13 @@ class PackagesModule { // Add dependency search if (dependency) { validateParameter(dependency, "dependency", String); + // Dependencies are stored as id@version (npm convention). Clients send id#version + // or id|version (the Java PackageClient sends |), or just id for any version. let depQuery; - if (dependency.includes('#')) { - depQuery = `${dependency}%`; - } else if (dependency.includes('|')) { - depQuery = `${dependency.replace('|', '#')}%`; + if (/[#|@]/.test(dependency)) { + depQuery = `${dependency.replace(/[#|]/, '@')}%`; } else { - depQuery = `${dependency}#%`; + depQuery = `${dependency}@%`; } conditions.push({ @@ -241,6 +309,12 @@ class PackagesModule { }); } + // Free text (npm search): every term must appear in the id or the description. + // npm qualifiers (keywords:, author:, scope: etc.) have no equivalent here and are ignored. + for (const term of text.split(/\s+/).filter(t => t && !/^[a-z-]+:/.test(t))) { + conditions.push({ operator: 'TEXT', value: term }); + } + // Build appropriate base query if (versioned) { baseQuery = `SELECT Id, Version, PubDate, FhirVersions, Kind, Canonical, Description @@ -412,7 +486,6 @@ class PackagesModule { totalResources: 0, // Packages don't track individual resources totalPackages: tableCounts.packages || 0, totalVersions: tableCounts.packageVersions || 0, - version: '4.0.1', crawlerEnabled: this.config.crawler.enabled, lastCrawlerRun: this.lastRunTime, totalCrawlerRuns: this.totalRuns @@ -426,7 +499,6 @@ class PackagesModule { totalResources: 0, totalPackages: 0, totalVersions: 0, - version: '4.0.1', crawlerEnabled: false, lastCrawlerRun: null, totalCrawlerRuns: 0 @@ -434,8 +506,15 @@ class PackagesModule { } } + // The database file, resolved the same way initializeDatabase opens it: a relative + // config.database is relative to the data directory, not the working directory. + getDatabasePath() { + return path.isAbsolute(this.config.database) ? this.config.database : folders.filePath('packages', this.config.database); + } + getDatabaseAgeInfo() { - if (!fs.existsSync(this.config.database)) { + const dbPath = this.getDatabasePath(); + if (!fs.existsSync(dbPath)) { return { lastModified: null, daysOld: null, @@ -443,10 +522,11 @@ class PackagesModule { }; } - const stats = fs.statSync(this.config.database); + const stats = fs.statSync(dbPath); const lastModified = stats.mtime; const now = new Date(); - const ageInDays = Math.floor((now - lastModified) / (1000 * 60 * 60 * 24)); + // Clamped: a file timestamp slightly ahead of this clock (e.g. on a network mount) is 'Today', not '-1 days ago' + const ageInDays = Math.max(0, Math.floor((now - lastModified) / (1000 * 60 * 60 * 24))); return { lastModified: lastModified, @@ -596,7 +676,7 @@ class PackagesModule { async initializeDatabase() { return new Promise((resolve, reject) => { // Use config path if absolute, otherwise resolve relative to data dir - const dbPath = path.isAbsolute(this.config.database) ? this.config.database : folders.filePath('packages', this.config.database); + const dbPath = this.getDatabasePath(); // Ensure directory exists const dbDir = path.dirname(dbPath); @@ -909,23 +989,64 @@ class PackagesModule { } setupRoutes() { - // Parameter validation configs - const searchParams = { - name: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ }, - dependson: { maxLength: 100, pattern: /^[a-zA-Z0-9._#-]*$/ }, - pkgcanonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*%?$/ }, - canonical: { maxLength: 200, pattern: /^[a-zA-Z0-9._:/-]*$/ }, - fhirversion: { maxLength: 10, pattern: /^(R2|R2B|R3|R4|R4B|R5|R6)?$/ }, - dependency: { maxLength: 100, pattern: /^[a-zA-Z0-9._#|-]*$/ }, - sort: { maxLength: 20, pattern: /^-?(name|version|date|count|fhirversion|kind|canonical)$/ }, - objWrapper: { maxLength: 10, pattern: /^(true|false)?$/ } - }; + const searchParams = SEARCH_PARAMS; + const updatesParams = UPDATES_PARAMS; - const updatesParams = { - dateType: { maxLength: 10, pattern: /^(relative|absolute)?$/, default: 'relative' }, - daysValue: { maxLength: 3, pattern: /^\d{1,3}$/, default: '10' }, - dateValue: { maxLength: 10, pattern: /^\d{4}-\d{2}-\d{2}$/, default: new Date().toISOString().split('T')[0] } - }; + // OpenAPI description of this API: /openapi.json, /openapi.yaml, and /openapi (an HTML + // reference for browsers, the JSON otherwise). Registered before /:id, which would + // otherwise treat "openapi" as a package id. + this.router.get('/openapi.json', (req, res) => { + const start = Date.now(); + try { + res.json(packagesOpenApi.getSpec()); + } catch (error) { + pckLog.error('Error in /packages/openapi.json:', error); + res.status(500).json({error: 'Failed to load the OpenAPI description', message: error.message}); + } finally { + this.stats.countRequest('openapi', Date.now() - start); + } + }); + + this.router.get('/openapi.yaml', (req, res) => { + const start = Date.now(); + try { + res.setHeader('Content-Type', 'application/yaml'); + res.send(packagesOpenApi.getYaml()); + } catch (error) { + pckLog.error('Error in /packages/openapi.yaml:', error); + res.status(500).json({error: 'Failed to load the OpenAPI description', message: error.message}); + } finally { + this.stats.countRequest('openapi', Date.now() - start); + } + }); + + this.router.get('/openapi', async (req, res) => { + const start = Date.now(); + const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html'); + try { + if (!acceptsHtml) { + res.json(packagesOpenApi.getSpec()); + return; + } + if (!htmlServer.hasTemplate('packages')) { + htmlServer.loadTemplate('packages', path.join(__dirname, 'packages-template.html')); + } + const content = packagesOpenApi.renderHtml(); + const stats = await this.gatherPackageStatistics(); + stats.processingTime = Date.now() - start; + res.setHeader('Content-Type', 'text/html'); + res.send(htmlServer.renderPage('packages', 'Package Server API', content, stats)); + } catch (error) { + pckLog.error('Error in /packages/openapi:', error); + if (acceptsHtml) { + htmlServer.sendErrorResponse(res, 'packages', error); + } else { + res.status(500).json({error: 'Failed to load the OpenAPI description', message: error.message}); + } + } finally { + this.stats.countRequest('openapi', Date.now() - start); + } + }); // GET /packages/catalog - Search packages or get updates this.router.get('/catalog', this.validateQueryParams(searchParams), async (req, res) => { @@ -944,12 +1065,11 @@ class PackagesModule { }); // GET /packages/-/v1/search - Search packages (v1 API) - this.router.get('/-/v1/search', this.validateQueryParams(searchParams), async (req, res) => { + this.router.get('/-/v1/search', this.validateQueryParams(V1_SEARCH_PARAMS), async (req, res) => { const start = Date.now(); try { try { - req.query.objWrapper = 'true'; - await this.serveSearch(req, res); + await this.serveV1Search(req, res); pckLog.info("/search?" + searchParams); } catch (error) { pckLog.error('Error in /packages/-/v1/search:', error); @@ -1056,9 +1176,7 @@ class PackagesModule { }); // GET /packages/broken - this.router.get('/broken', this.validateQueryParams({ - filter: { maxLength: 100, pattern: /^[a-zA-Z0-9._-]*$/ } - }), async (req, res) => { + this.router.get('/broken', this.validateQueryParams(BROKEN_PARAMS), async (req, res) => { const start = Date.now(); try { try { @@ -1082,13 +1200,11 @@ class PackagesModule { // Validate path parameters const {id, version} = req.params; - if (!id || !version || - !/^(@[a-z0-9._-]+\/)?[a-zA-Z0-9._-]+$/.test(id) || - !/^[a-zA-Z0-9._-]+$/.test(version)) { + if (!id || !version || !PACKAGE_ID_PATTERN.test(id) || !PACKAGE_VERSION_PATTERN.test(version)) { return res.status(400).json({error: `Invalid package id or version format: ${id}`}); } - if (id.length > 100 || version.length > 50) { + if (id.length > PACKAGE_ID_MAX_LENGTH || version.length > PACKAGE_VERSION_MAX_LENGTH) { return res.status(400).json({error: `Package id or version too long: ${id}`}); } @@ -1146,7 +1262,7 @@ class PackagesModule { }); // GET /packages/:id - Get package versions - this.router.get('/:id', async (req, res) => { + this.router.get('/:id', async (req, res, next) => { const start = Date.now(); try { @@ -1154,10 +1270,13 @@ class PackagesModule { const {id} = req.params; const {sort} = req.query; - // Don't process routes that are handled elsewhere + // Don't process routes that are handled elsewhere. These must call next(): + // returning without responding leaves the request hanging, which is what + // /status, /stats and /search (registered after this route) used to do. if (['catalog', 'log', 'broken', 'stats', 'status', 'search', 'updates'].includes(id) || id.endsWith('.html') || id === '-') { - return; // Let other routes handle these + next(); + return; } await this.serveVersions(id, sort, req.secure, req, res); @@ -1277,10 +1396,6 @@ class PackagesModule { totalRuns: this.totalRuns, lastLog: this.lastCrawlerLog || null }, - paths: { - database: this.config.database, - mirror: this.config.mirrorPath - }, config: { masterUrl: this.config.masterUrl } @@ -1794,7 +1909,7 @@ class PackagesModule { const versionObj = { name: id, - _id: `${id}@${this.interpretVersion(pv.FhirVersions)}`, + _id: `${id}@${pv.Version}`, version: pv.Version, date: new Date(pv.PubDate).toISOString(), fhirVersion: this.interpretVersion(pv.FhirVersions), @@ -1845,7 +1960,7 @@ class PackagesModule { buildTarballUrl(id, version, secure, req) { if (this.config.bucketPath) { let bucketUrl = this.getBucketUrl(secure); - return `${bucketUrl}${id}-${version}.tgz`; + return `${bucketUrl}${this.fixPrefix(id)}-${version}.tgz`; } else { // Use direct server URL const protocol = secure ? 'https' : 'http'; @@ -1987,6 +2102,52 @@ class PackagesModule { return escape(text).replace(/\n/g, '
'); } + // GET /-/v1/search: the npm registry search API. Takes the FHIR search parameters plus + // npm's text/size/from, and returns npm's result shape. Without size, every result is + // returned (npm's own clients always send size). + async serveV1Search(req, res) { + const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html'); + if (acceptsHtml) { + await this.serveSearch(req, res); + return; + } + + const q = req.query; + const secure = req.secure || req.headers['x-forwarded-proto'] === 'https'; + try { + const results = await this.searchPackages({ + name: q.name, + dependson: q.dependson, + canonicalPkg: q.pkgcanonical, + canonicalUrl: q.canonical, + fhirVersion: q.fhirversion, + dependency: q.dependency, + text: q.text, + sort: q.sort + }, req, secure); + + const from = parseInt(q.from, 10) || 0; + const page = q.size + ? results.slice(from, from + Math.min(parseInt(q.size, 10), V1_SEARCH_MAX_SIZE)) + : results.slice(from); + + res.setHeader('Content-Type', 'application/json'); + res.json({ + objects: page.map(pkg => ({ + // The npm CLI requires maintainers (it calls maintainers.map) and reads keywords + package: { ...pkg, keywords: [], maintainers: [] }, + score: { final: 1, detail: { quality: 1, popularity: 1, maintenance: 1 } }, + searchScore: 1 + })), + total: results.length, + time: new Date().toUTCString() + }); + } catch (error) { + pckLog.error('Error in v1 search:', error); + res.status(500).json({error: 'Search failed', message: error.message}); + } + } + async serveSearch(req, res) { const { name = '', @@ -2029,7 +2190,7 @@ class PackagesModule { // Return JSON response let responseData; - if (objWrapper) { + if (objWrapper === true || objWrapper === 'true') { // V1 API format with object wrapper responseData = { objects: results.map(pkg => ({package: pkg})) @@ -2265,18 +2426,6 @@ class PackagesModule { return str.replace(/'/g, "''"); } - getVersion(fhirVersion) { - // Map common FHIR version aliases to actual versions - const versionMap = { - 'R2': '1.0.2', - 'R3': '3.0.2', - 'R4': '4.0.1', - 'R5': '5.0.0' - }; - - return versionMap[fhirVersion] || fhirVersion; - } - interpretVersion(fhirVersions) { if (!fhirVersions) return ''; @@ -2300,7 +2449,7 @@ class PackagesModule { buildPackageUrl(id, version, secure = false, req = null) { if (this.config.bucketPath) { let bucketUrl = this.getBucketUrl(secure); - return `${bucketUrl}${id}-${version}.tgz`; + return `${bucketUrl}${this.fixPrefix(id)}-${version}.tgz`; } else { // Use direct server URL const protocol = secure ? 'https' : 'http'; @@ -2337,6 +2486,15 @@ class PackagesModule { case 'count': comparison = (a.count || 0) - (b.count || 0); break; + case 'fhirversion': + comparison = this.compareVersions(a.fhirVersion || '', b.fhirVersion || ''); + break; + case 'kind': + comparison = (a.kind || '').localeCompare(b.kind || ''); + break; + case 'canonical': + comparison = (a.canonical || '').localeCompare(b.canonical || ''); + break; default: return 0; } @@ -2345,20 +2503,38 @@ class PackagesModule { }); } + // Semver-style comparison: numeric major.minor.patch, then a version with a pre-release + // label (1.0.0-ballot) sorts before the release (1.0.0). Anything unparseable falls back + // to string order, so the result is always a number (Array.sort misbehaves on NaN). compareVersions(a, b) { - const aParts = a.split('.').map(Number); - const bParts = b.split('.').map(Number); - - for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { - const aPart = aParts[i] || 0; - const bPart = bParts[i] || 0; - + const parse = v => { + const [core, ...pre] = String(v).trim().split('-'); + return { nums: core.split('.').map(n => parseInt(n, 10)), pre: pre.join('-') }; + }; + const va = parse(a); + const vb = parse(b); + + for (let i = 0; i < Math.max(va.nums.length, vb.nums.length); i++) { + const aPart = va.nums[i] || 0; + const bPart = vb.nums[i] || 0; + if (Number.isNaN(aPart) || Number.isNaN(bPart)) { + return String(a).localeCompare(String(b)); + } if (aPart !== bPart) { return aPart - bPart; } } - return 0; + if (va.pre === vb.pre) { + return 0; + } + if (!va.pre) { + return 1; + } + if (!vb.pre) { + return -1; + } + return va.pre.localeCompare(vb.pre, undefined, { numeric: true }); } generateSearchHtml(req, results, params) { @@ -2773,12 +2949,12 @@ class PackagesModule { getStatus() { return { enabled: true, + // No file system paths here: this is served publicly (/packages/status and the + // server health check) database: { - connected: this.db ? true : false, - path: this.config.database + connected: this.db ? true : false }, mirror: { - path: this.config.mirrorPath, exists: fs.existsSync(this.config.mirrorPath) }, crawler: { @@ -2857,4 +3033,11 @@ class PackagesModule { } } +PackagesModule.QUERY_PARAMS = { search: SEARCH_PARAMS, v1Search: V1_SEARCH_PARAMS, updates: UPDATES_PARAMS, broken: BROKEN_PARAMS }; +PackagesModule.FHIR_RELEASE_VERSIONS = FHIR_RELEASE_VERSIONS; +PackagesModule.PATH_PARAMS = { + id: { maxLength: PACKAGE_ID_MAX_LENGTH, pattern: PACKAGE_ID_PATTERN }, + version: { maxLength: PACKAGE_VERSION_MAX_LENGTH, pattern: PACKAGE_VERSION_PATTERN } +}; + module.exports = PackagesModule; \ No newline at end of file diff --git a/packages/readme.md b/packages/readme.md new file mode 100644 index 00000000..1ae527e5 --- /dev/null +++ b/packages/readme.md @@ -0,0 +1,120 @@ +# Package Server + +A registry of FHIR NPM packages. It crawls the package feeds listed in the FHIR IG +registry, stores every package version it finds, and serves them using the npm +registry protocol, with FHIR-specific search on top (by canonical URL, FHIR version +and dependency). It is the server behind packages2.fhir.org. + +Its main clients are the HL7 Java tools (`org.hl7.fhir.utilities.npm.PackageClient`, +used by the IG Publisher and validator). Package documents, tarball URLs and search +results follow npm's formats, so npm clients can point `--registry` at +`/packages`. + +## API + +The public API is described by an OpenAPI 3.1 spec: + +| URL | | +|---|---| +| `/packages/openapi` | Browsable reference (HTML), with a "try it" form for each GET operation | +| `/packages/openapi.json` | The spec as JSON | +| `/packages/openapi.yaml` | The spec as YAML (the source file, [openapi.yaml](openapi.yaml)) | + +Every response from the package server carries a +`Link: ; rel="service-desc"` header (RFC 8631), and the +HTML pages carry the matching `` element. + +In summary: + +| Endpoint | Purpose | +|---|---| +| `GET /packages/catalog` | Search packages: `name`, `canonical`, `pkgcanonical`, `fhirversion`, `dependency`, `dependson`, `sort` | +| `GET /packages/-/v1/search` | npm search API: `text`, `size`, `from`, plus the `/catalog` parameters | +| `GET /packages/{id}` | npm package document: all versions, dependencies, tarball URLs | +| `GET /packages/{id}/{version}` | Download a package tarball | +| `GET /packages/updates` | Versions published since a date | +| `GET /packages/broken` | Package versions whose dependencies this registry doesn't hold | + +Most endpoints return an HTML page instead of JSON when the request's `Accept` +header contains `text/html`. The JSON is the contract; the HTML is for people. + +Query parameters are validated strictly. An unknown parameter, a repeated parameter, +or a value that's too long or badly formed is a 400, not silently ignored. + +Not in the spec, deliberately: + +* Operational pages: `/packages/stats`, `/packages/log` (the last crawler run), + `/packages/status`. +* `POST /packages/crawl`, which runs a full crawl on demand. It requires the + `x-crawl-token` header to match the `crawlToken` setting, and is disabled when no + token is configured. + +### Keeping the spec honest + +[openapi.yaml](openapi.yaml) is maintained by hand. `tests/packages/openapi.test.js` +fails if: + +* a route is added to the router without being described in the spec, or listed in + the test's `EXCLUDED` table with a reason; +* the spec describes a route the router doesn't have; +* a query or path parameter's name, length limit, pattern or default differs from the + validation rules in `packages.js` (`SEARCH_PARAMS`, `V1_SEARCH_PARAMS`, + `UPDATES_PARAMS`, `BROKEN_PARAMS`, `PATH_PARAMS`). + +So when you change a route or a parameter rule, change `openapi.yaml` with it. +`tests/packages/search.test.js` covers the search behaviour itself against an +in-memory database. + +## Configuration + +In the server config, under `modules.packages`: + +```json +"packages": { + "enabled": true, + "database": "packages.db", + "mirrorPath": "/absolute/path/to/mirror", + "crawlToken": "a-long-random-secret", + "crawler": { + "enabled": true, + "schedule": "0 * * * *" + } +} +``` + +| Setting | | +|---|---| +| `database` | The SQLite database. A relative path is resolved against the data directory's `packages` folder. Created on first run. | +| `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`. | +| `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. | +| `baseUrl` | Optional. The public base URL used when building package URLs. Defaults to the request's host. | +| `crawlToken` | Shared secret for `POST /packages/crawl`. Omit it to disable that endpoint; the scheduled crawler is unaffected. | +| `crawler.enabled` | Run the crawler at startup and on the schedule. | +| `crawler.schedule` | Cron expression for crawls. | +| `masterUrl` | The list of feeds to crawl. Defaults to `https://fhir.github.io/ig-registry/package-feeds.json`. | +| `localFeedDirs` | Optional. Directories feeds may be read from as local files (for testing). | +| `allowPrivateAddresses` | Optional, for testing only. Lets the crawler fetch from private and loopback addresses, which it otherwise refuses (SSRF protection). | + +## Crawling + +Each crawl reads the master feed list, then each RSS feed in it (Simplifier last). +For each item, it downloads the package, checks the id, version and canonical, and +stores it. + +* The master list's `package-restrictions` say which feeds may publish which package + ids. A package from a feed that isn't allowed to publish it is skipped. +* Items marked `notForPublication` are skipped. +* Feeds can be paginated (RFC 5005 `rel="next"`). The first page is read on every + crawl; older pages are read once and remembered in the `FeedPages` table. +* A feed that rate-limits (HTTP 429) is abandoned for that crawl and retried next + time. + +`/packages/log` shows what the last crawl did, feed by feed and item by item. + +## Storage + +Everything is in the SQLite database. `PackageVersions` holds each version's +metadata and the tarball itself. `Packages` holds one row per package id, with its +current version and download count. `PackageDependencies` (stored as `id@version`), +`PackageFHIRVersions` and `PackageURLs` (the canonical URLs of the resources in each +version) support search. From 9b7186f140888e8c9bc8d2e560f0aaa12b35234e Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 5 Oct 2026 18:04:22 +1300 Subject: [PATCH 03/16] setup openAPI for tx-ecosystem registry, and fix various related bugs --- registry/api.js | 153 +++++++++++----- registry/model.js | 7 + registry/openapi.js | 13 ++ registry/openapi.yaml | 311 +++++++++++++++++++++++++++++++ registry/readme.md | 315 +++++++++++--------------------- registry/registry-template.html | 2 + registry/registry.js | 79 ++++++-- 7 files changed, 612 insertions(+), 268 deletions(-) create mode 100644 registry/openapi.js create mode 100644 registry/openapi.yaml diff --git a/registry/api.js b/registry/api.js index b0c3713e..c937486c 100644 --- a/registry/api.js +++ b/registry/api.js @@ -3,6 +3,29 @@ const { ServerRegistryUtilities } = require('./model'); const escape = require('escape-html'); +const RELEASE_VERSIONS = { + R2: '1.0', + R2B: '1.4', + R3: '3.0', + R4: '4.0', + R4B: '4.3', + R5: '5.0', + R6: '6.0' +}; + +// The ecosystem IG reports a server's security as boolean flags (open, password, token, +// oauth, smart, cert). The crawler records a single string; this maps it. 'api-key' is a +// token. The string itself is still reported as 'security' for existing clients. +function securityFlags(security) { + switch (security) { + case 'open': return { open: true }; + case 'api-key': return { token: true }; + case 'password': case 'token': case 'oauth': case 'smart': case 'cert': + return { [security]: true }; + default: return {}; + } +} + class RegistryAPI { constructor(crawler) { this.crawler = crawler; @@ -317,16 +340,11 @@ class RegistryAPI { }); } + // Release codes (R4, r4b ...) to the version prefix they select; anything else (4.0.1, + // 4.0) is used as given. R4B and R2B can't be derived from the number alone. _normalizeFhirVersion(version) { if (!version) return version; - - // Convert R4 or r4 to 4.0, R5 or r5 to 5.0, etc. - const rMatch = /^[rR](\d+)$/.exec(version); - if (rMatch) { - return `${rMatch[1]}.0`; - } - - return version; + return RELEASE_VERSIONS[version.toUpperCase()] || version; } /** @@ -471,38 +489,9 @@ class RegistryAPI { }); }); - // NEW: Fallback - if no matches found, check for authoritative pattern matches - if (authMatches.length === 0 && result.candidates.length === 0) { - data.registries.forEach(registry => { - registry.servers.forEach(server => { - // Excluded content stays hidden in the fallback path too - if (server.isExcludedTarget(codeSystem)) return; - - // Check if server supports the requested usage tag - if (server.usageList.length === 0 || - (usage && server.usageList.includes(usage))) { - - // Check if server is authoritative for this code system - // (taking language specific claims into account) - const authMatch = server.matchAuthCS(codeSystem, requestLangs); - - if (authMatch.isAuth) { - server.versions.forEach(version => { - if (ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) { - authMatches.push({ - entry: this.createServerEntry(server, version, null, authMatch), - score: authMatch.score - }); - if (!matchedServers.includes(server.code)) { - matchedServers.push(server.code); - } - } - }); - } - } - }); - }); - } + // No fallback to servers that claim authority but don't host the code system: per the + // ecosystem IG, 'Servers are not listed as authoritative unless they actually host the + // CodeSystem(+version) in the request'. // Language specific matches rank before authoritative-list matches, most specific // match first. Array.prototype.sort is stable, so entries with equal scores (e.g. @@ -598,6 +587,86 @@ class RegistryAPI { }; } + /** + * The ecosystem Discovery API (GET {root} as JSON): one row per server endpoint. + * + * Filters, all optional: registry and server (codes), fhirVersion (RX or M.n.p), url (a + * code system, url or url|version), authoritativeOnly, language (only with url). + * + * With url, only endpoints that host that code system are listed (an excluded code + * system hides the server), and the row's 'candidate' list says whether it's hosted but + * not claimed; authoritativeOnly then keeps only the endpoints authoritative for it. + * Without url, authoritative/authoritative-valuesets are the server's claim masks and the + * candidate lists are omitted - listing everything a server hosts would be huge. + */ + discover(params = {}) { + const { registry = '', server = '', fhirVersion = '', url = '', language = '' } = params; + const authoritativeOnly = params.authoritativeOnly === true || params.authoritativeOnly === 'true'; + const normalizedVersion = this._normalizeFhirVersion(fhirVersion); + const requestLangs = url ? ServerRegistryUtilities.parseAcceptLanguage(language) : null; + const data = this.crawler.getData(); + + const authRows = []; + const otherRows = []; + data.registries.forEach(reg => { + if (registry && reg.code !== registry) return; + reg.servers.forEach(srv => { + if (server && srv.code !== server) return; + if (url && srv.isExcludedTarget(url)) return; + + const authMatch = url ? srv.matchAuthCS(url, requestLangs) : { isAuth: false, scoped: false }; + + srv.versions.forEach(version => { + if (normalizedVersion && !ServerRegistryUtilities.versionMatches(normalizedVersion, version.version)) { + return; + } + let hosted = false; + if (url) { + hosted = ServerRegistryUtilities.hasMatchingCodeSystem(url, version.codeSystems, false, {}); + if (!hosted || (authoritativeOnly && !authMatch.isAuth)) { + return; + } + } + + const row = { + 'server-name': srv.name, + 'server-code': srv.code, + 'registry-name': reg.name, + 'registry-code': reg.code, + 'registry-url': reg.address, + url: version.address, + fhirVersion: version.version, + error: version.error || null, + 'last-success': version.lastSuccess ? Math.floor(Date.now() - new Date(version.lastSuccess).getTime()) : null, + systems: version.codeSystems.length, + authoritative: [...srv.authCSList], + 'authoritative-valuesets': [...srv.authVSList] + }; + if (url && !authMatch.isAuth) { + row.candidate = [url]; + } + if (authMatch.isAuth && authMatch.scoped) { + row.languages = [authMatch.tag]; + } + if (version.security) { + row.security = version.security; + Object.assign(row, securityFlags(version.security)); + } + (authMatch.isAuth ? authRows : otherRows).push({ row, score: authMatch.score || 0 }); + }); + }); + }); + + // Endpoints authoritative for url first (language specific matches before the rest; + // sort is stable, so registration order is kept otherwise) + authRows.sort((a, b) => a.score - b.score); + return { + 'last-update': data.lastRun ? new Date(data.lastRun).toISOString() : null, + 'master-url': data.address, + results: [...authRows, ...otherRows].map(r => r.row) + }; + } + _cleanEmptyArrays(result) { const cleanedResult = { ...result }; @@ -620,11 +689,13 @@ class RegistryAPI { createServerEntry(server, version, content = null, authMatch = null) { const entry = { 'server-name': server.name, - url: version.address + url: version.address, + fhirVersion: version.version }; if (version.security) { entry.security = version.security; + Object.assign(entry, securityFlags(version.security)); } if (server.accessInfo) { entry.access_info = server.accessInfo; diff --git a/registry/model.js b/registry/model.js index 932210d5..d26441fc 100644 --- a/registry/model.js +++ b/registry/model.js @@ -561,6 +561,13 @@ class ServerRegistryUtilities { } else { // Otherwise do exact matching on both full and base URL ok = vurl === cs || vurl === baseCs; + // A SNOMED CT version URI extends its edition URI, so a server hosting + // sct|.../{edition}/version/{date} hosts the edition sct|.../{edition}. Servers + // list only full versions, so without this an edition-level request would never + // find the servers that host the edition. + if (!ok && noVersionIndependentMatching && cs.includes('|')) { + ok = vurl.startsWith(cs + '/'); + } } if (ok && content) { content.content = item.content; diff --git a/registry/openapi.js b/registry/openapi.js new file mode 100644 index 00000000..8bc1821f --- /dev/null +++ b/registry/openapi.js @@ -0,0 +1,13 @@ +// +// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au) +// +// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause +// + +// The terminology registry's OpenAPI description. openapi.yaml is the source of truth; see +// the comment at its top. + +const path = require('path'); +const { createOpenApiDoc } = require('../library/openapi-doc'); + +module.exports = createOpenApiDoc(path.join(__dirname, 'openapi.yaml'), '/tx-reg'); diff --git a/registry/openapi.yaml b/registry/openapi.yaml new file mode 100644 index 00000000..23436080 --- /dev/null +++ b/registry/openapi.yaml @@ -0,0 +1,311 @@ +# OpenAPI description of the FHIRsmith terminology server registry (/tx-reg) - the +# coordination server of the HL7 terminology ecosystem. +# +# The normative description of this API is the ecosystem page of the terminology +# ecosystem IG (https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/ecosystem.html); this +# file describes what this server implements, including its extensions. It is maintained by +# hand, and tests/registry/openapi.test.js keeps it honest: every route the router registers +# must be described here or explicitly excluded, and the documented query parameters must +# be the ones the handlers read (DISCOVERY_PARAMS, RESOLVE_PARAMS in registry.js). +# +# info.version is filled in from package.json when the spec is served. + +openapi: 3.1.0 +info: + title: FHIRsmith Terminology Server Registry + version: "0.0.0" + summary: Find the right terminology server for a code system or value set + description: | + The coordination server of a terminology server ecosystem. It regularly scans the + servers listed in the ecosystem's master registration file - their + CapabilityStatement, TerminologyCapabilities and ValueSets - and answers two + questions: + + * **Discovery** (`GET /`): which terminology server endpoints are there? + * **Resolution** (`GET /resolve`): which server should be used for this code system + or value set? + + Servers can claim to be **authoritative** for code systems and value sets (by URL + mask) in their registration. A server that hosts the content but makes no claim is a + **candidate**. A server is only ever returned for content it actually hosts. + + **Content negotiation.** If the request's `Accept` header contains `text/html`, these + endpoints return a browsable HTML page instead of JSON. The JSON form is the contract. + + **Parameters are read leniently**: unknown parameters are ignored, and a repeated + parameter takes its first value. + license: + name: BSD-3-Clause + url: https://opensource.org/license/bsd-3-clause +externalDocs: + description: Terminology ecosystem IG - the ecosystem and the coordination server API + url: https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/ecosystem.html +servers: + - url: /tx-reg +tags: + - name: discovery + description: List the servers in the ecosystem + - name: resolution + description: Choose a server for a code system or value set + +paths: + /: + get: + tags: [discovery] + operationId: discover + summary: List terminology server endpoints + description: | + Lists the server endpoints in the ecosystem, one entry per server per FHIR + version, optionally filtered. + + With `url`, only endpoints that host that code system are listed, those + authoritative for it first, and each entry's `candidate` list says whether the + endpoint hosts it without claiming authority. A server that excludes the code + system is not listed. + + Without `url`, each entry's `authoritative` and `authoritative-valuesets` are the + server's claim masks, and there are no candidate lists (listing everything a server + hosts would make the response very large). + parameters: + - name: registry + in: query + description: Only endpoints from this registry (its code in the master registration file). + schema: { type: string } + - name: server + in: query + description: Only endpoints of this server (its code in its registry). + schema: { type: string } + example: tx.fhir.org + - $ref: '#/components/parameters/fhirVersionOptional' + - name: url + in: query + description: Only endpoints that host this code system (`url` or `url|version`). + schema: { type: string } + example: http://loinc.org + - name: authoritativeOnly + in: query + description: With `url`, only endpoints that are authoritative for it. + schema: { type: string, enum: ['true', 'false'], default: 'false' } + - $ref: '#/components/parameters/language' + responses: + '200': + description: The matching endpoints + content: + application/json: + schema: { $ref: '#/components/schemas/DiscoveryResult' } + text/html: + schema: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '500': { $ref: '#/components/responses/ServerError' } + + /resolve: + get: + tags: [resolution] + operationId: resolve + summary: Choose a server for a code system or value set + description: | + Returns the endpoints to use for a code system (`url`) or a value set (`valueSet`) + at a FHIR version: those authoritative for it, then the other candidates that host + it. Choosing between several is up to the client. + + * An endpoint is only listed if it hosts the code system (+version). A SNOMED CT + edition (`http://snomed.info/sct|http://snomed.info/sct/{edition}`) is hosted by an + endpoint that hosts any version of that edition. + * An endpoint listed as authoritative is not also listed as a candidate. + * A server whose registration has a `usage` list is only returned if the request's + `usage` is in it. + * A server that excludes the code system or value set is never returned. + * `authoritative` and `candidates` are omitted when empty. + + With `language`, endpoints matched on a language specific claim come first in + `authoritative`, and candidates are marked `language-support: unknown`. Value set + resolution has no language dimension. + + A browser request (`Accept: text/html`) without the required parameters returns a + form for making the request. + parameters: + - name: fhirVersion + in: query + required: true + description: The FHIR version, as a release code (`R4`, `R4B`, `R5` ...) or a version (`4.0.1`, `4.0`). + schema: { type: string } + example: R4 + - name: url + in: query + description: The code system, `url` or `url|version`. One of `url` and `valueSet` is required. + schema: { type: string } + example: http://snomed.info/sct|http://snomed.info/sct/32506021000036107 + - name: version + in: query + description: | + A version for `url`, as an alternative to `url|version`. Ignored if `url` already + has a version. (An extension to the ecosystem IG.) + schema: { type: string } + - name: valueSet + in: query + description: The value set, `url` or `url|version`. + schema: { type: string } + example: http://hl7.org/fhir/ValueSet/observation-codes + - name: authoritativeOnly + in: query + description: Only return authoritative endpoints (no candidates). + schema: { type: string, enum: ['true', 'false'], default: 'false' } + - $ref: '#/components/parameters/language' + - name: usage + in: query + description: | + What the client is doing, for servers that restrict their use. The HL7 Java + tools send `publication`, `validation` or `code-generation`. + schema: { type: string } + example: publication + responses: + '200': + description: The endpoints to use + content: + application/json: + schema: { $ref: '#/components/schemas/ResolveResult' } + text/html: + schema: { type: string } + '400': + description: A required parameter is missing, or a URL is malformed + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + +components: + parameters: + fhirVersionOptional: + name: fhirVersion + in: query + description: Only endpoints for this FHIR version, as a release code (`R4`, `R4B`, `R5` ...) or a version (`4.0.1`, `4.0`). + schema: { type: string } + example: R4 + language: + name: language + in: query + description: | + The language of the request being routed, in Accept-Language syntax: one BCP-47 + tag, or a weighted list (`de-AT, de;q=0.9, en;q=0.1`). Pass it only when the + operation is language-sensitive. With discovery, only meaningful with `url`. + schema: { type: string } + example: de + + responses: + BadRequest: + description: A parameter is malformed + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + ServerError: + description: The server failed to process the request + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + schemas: + SecurityFlags: + type: object + description: | + How the endpoint can be accessed, as the boolean flags of the ecosystem IG (`open` + for unauthenticated use; an API key is reported as `token`), plus the scanner's own + `security` string (`open` or `api-key`), kept for existing clients. + properties: + security: { type: string, enum: [open, api-key] } + open: { type: boolean, const: true } + password: { type: boolean, const: true } + token: { type: boolean, const: true } + oauth: { type: boolean, const: true } + smart: { type: boolean, const: true } + cert: { type: boolean, const: true } + + DiscoveryResult: + type: object + required: [last-update, master-url, results] + properties: + last-update: + type: [string, 'null'] + format: date-time + description: When the ecosystem was last scanned + master-url: { type: string, format: uri, description: The master registration file that was scanned } + results: + type: array + items: { $ref: '#/components/schemas/DiscoveryEntry' } + + DiscoveryEntry: + allOf: + - $ref: '#/components/schemas/SecurityFlags' + - type: object + required: [server-name, server-code, registry-name, registry-code, url, fhirVersion, systems, authoritative, authoritative-valuesets] + properties: + server-name: { type: string } + server-code: { type: string } + registry-name: { type: string } + registry-code: { type: string } + registry-url: { type: string, format: uri } + url: { type: string, format: uri, description: The FHIR endpoint } + fhirVersion: { type: string, example: 4.0.1 } + error: + type: [string, 'null'] + description: What went wrong the last time the endpoint was scanned; null if it worked + last-success: + type: [integer, 'null'] + description: Milliseconds since the endpoint was last scanned successfully; null if never + systems: { type: integer, description: Number of code systems the endpoint hosts } + authoritative: + type: array + items: { type: string } + description: The code system masks the server claims authority for + authoritative-valuesets: + type: array + items: { type: string } + description: The value set masks the server claims authority for + candidate: + type: array + items: { type: string } + description: With `url`, present when the endpoint hosts it without claiming authority + languages: + type: array + items: { type: string } + description: With `url` and `language`, the language tag of the claim the endpoint matched on + + ResolveResult: + type: object + required: [formatVersion, registry-url] + properties: + formatVersion: { type: string, const: '1' } + registry-url: { type: string, format: uri, description: The master registration file } + authoritative: + type: array + items: { $ref: '#/components/schemas/ResolvedServer' } + candidates: + type: array + items: { $ref: '#/components/schemas/ResolvedServer' } + + ResolvedServer: + allOf: + - $ref: '#/components/schemas/SecurityFlags' + - type: object + required: [server-name, url, fhirVersion] + properties: + server-name: { type: string } + url: { type: string, format: uri, description: The FHIR endpoint to use } + fhirVersion: { type: string, example: 4.0.1 } + access_info: { type: string, description: How to get access (markdown), from the server's registration } + content: + type: string + enum: [not-present, example, fragment, complete, supplement] + description: Candidates only - the content mode of the code system on this server, if it says + languages: + type: array + items: { type: string } + description: Authoritative entries matched on a language specific claim - that claim's language tag + language-support: + type: string + const: unknown + description: Candidates, when the request had a language - the ecosystem can't say whether this server has it + + Error: + type: object + required: [error] + properties: + error: { type: string } diff --git a/registry/readme.md b/registry/readme.md index 618ff2d7..d0977b4c 100644 --- a/registry/readme.md +++ b/registry/readme.md @@ -1,225 +1,124 @@ # Terminology Server Registry -A Node.js module for crawling and querying FHIR terminology servers. This module maintains a registry of terminology servers, periodically crawls them to gather capability information, and provides an API to find the best server for specific code systems or value sets. - -## Architecture - -The module consists of three main components: - -### 1. Data Model (`registry-model.js`) -- **ServerRegistries**: Top-level container for all registry data -- **ServerRegistry**: Individual registry containing multiple servers -- **ServerInformation**: Server metadata and authoritative designations -- **ServerVersionInformation**: Version-specific server capabilities -- **ServerRow**: Flattened representation for API responses -- **ServerRegistryUtilities**: Helper functions for matching and filtering - -### 2. Crawler (`registry-crawler.js`) -- Periodically fetches capability statements from configured servers -- Extracts security models, supported code systems, and value sets -- Handles retries and error recovery -- Maintains current state of all servers - -### 3. API Processor (`registry-api.js`) -- Provides query endpoints for finding servers -- Ranks servers based on: - - Authoritative designation - - Availability (no errors) - - Recency of successful connection - - Number of resources available -- Supports filtering by registry, server, version, and resource - -## Installation - -```bash -npm install -``` - -## Usage - -### Basic Server Setup - -```javascript -const express = require('express'); -const RegistryCrawler = require('./crawler'); -const RegistryAPI = require('./api'); - -// Configure crawler -const crawler = new RegistryCrawler({ - timeout: 30000, - crawlInterval: 5 * 60 * 1000, // 5 minutes - registryConfigs: [ - { - code: 'main', - name: 'Main Registry', - servers: [ - { - code: 'tx1', - name: 'TX Server', - authCSList: ['http://loinc.org*'], - versions: [ - { - version: '4.0.1', - address: 'https://tx.fhir.org/r4' - } - ] - } - ] - } - ] -}); - -// Create API -const api = new RegistryAPI(crawler); - -// Set up Express -const app = express(); -api.registerRoutes(app); - -// Start crawler and server -crawler.start(); -app.listen(3000); -``` - -### API Endpoints - -#### Query Endpoints - -**Find servers for a code system:** -``` -GET /api/query/codesystem?system=http://loinc.org&version=4.0 -``` - -**Find servers for a value set:** -``` -GET /api/query/valueset?valueset=http://hl7.org/fhir/ValueSet/observation-codes -``` - -**Find the best server:** -``` -GET /api/best-server/codesystem?url=http://snomed.info/sct -``` - -#### Registry Information - -**Get statistics:** -``` -GET /api/registry/stats -``` - -**List all registries:** -``` -GET /api/registry -``` - -**Get servers in a registry:** -``` -GET /api/registry/main/servers -``` - -#### Admin Endpoints - -**Trigger manual crawl:** -``` -POST /api/admin/crawl -``` - -**Export/Import data:** -``` -GET /api/admin/data -POST /api/admin/data -``` +The coordination server of a terminology server ecosystem, as described by the +[terminology ecosystem IG](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/ecosystem.html). +It regularly scans the servers listed in the ecosystem's master registration file, and tells +clients which terminology server to use for a code system or value set. It runs at +http://tx.fhir.org/tx-reg for the HL7 ecosystem. + +Its main client is the HL7 Java tooling (`TerminologyClientManager` in org.hl7.fhir.r5), +which calls `/resolve` to route each terminology operation to the right server. + +## API + +The public API is described by an OpenAPI 3.1 spec: + +| URL | | +|---|---| +| `/tx-reg/openapi` | Browsable reference (HTML), with a "try it" form for each GET operation | +| `/tx-reg/openapi.json` | The spec as JSON | +| `/tx-reg/openapi.yaml` | The spec as YAML (the source file, [openapi.yaml](openapi.yaml)) | + +Every response carries a `Link: ; rel="service-desc"` header +(RFC 8631), and the HTML pages carry the matching `` element. + +| Endpoint | Purpose | +|---|---| +| `GET /tx-reg/` | **Discovery**: the server endpoints in the ecosystem. Filters: `registry`, `server`, `fhirVersion`, `url`, `authoritativeOnly`, `language` | +| `GET /tx-reg/resolve` | **Resolution**: which endpoints to use for a code system (`url`) or value set (`valueSet`) at a `fhirVersion`. Also `authoritativeOnly`, `language`, `usage`, `version` | + +Both return an HTML page instead of JSON when the request's `Accept` header contains +`text/html`. `/tx-reg/resolve` without parameters is a form for trying it out. +`/tx-reg/log` (the crawler log) is operational, and not in the spec. + +Parameters are read leniently: unknown parameters are ignored, and a repeated parameter +takes its first value. + +### How resolution decides + +* An endpoint is only returned for content it actually hosts, as reported by its + TerminologyCapabilities (code systems) and ValueSet search (value sets). +* A SNOMED CT edition (`http://snomed.info/sct|http://snomed.info/sct/{edition}`) is hosted + by any endpoint that hosts a version of that edition. Otherwise SNOMED CT versions match + exactly; for other code systems, a server that hosts the code system hosts all its + versions. +* Endpoints whose server claims authority (the `authoritative` masks in its registration) + are listed as `authoritative`; the others as `candidates`. +* With `language`, servers with a matching language specific claim (`languages` in the + registration) come first, most specific tag first; candidates are marked + `language-support: unknown`. +* A server with a `usage` list is only returned when the request's `usage` is in it. +* A server's `exclusions` hide the matching content from it entirely. +* FHIR versions can be given as release codes (`R4`, `R4B`, `R5`, `R6` ...) or numbers + (`4.0.1`, `4.0`). + +### Differences from the ecosystem IG + +* Discovery: without `url`, a row's `authoritative`/`authoritative-valuesets` are the + server's claim masks, and there are no candidate lists. With `url`, `candidate` is + `[url]` when the endpoint hosts it without claiming authority. +* Entries carry the IG's security flags (`open`, `token`...) and also a `security` string + (`open` or `api-key`). Note that this records how the *registry* reaches the server: an + endpoint is `api-key` when the registry is configured with a key for it (`apiKeys`), and + `open` otherwise. +* Resolve takes a `version` parameter, as an alternative to `url|version`. +* Resolve omits `authoritative` and `candidates` when they are empty. + +### Keeping the spec honest + +[openapi.yaml](openapi.yaml) is maintained by hand. `tests/registry/openapi.test.js` fails +if a route is added without being described (or listed in the test's `EXCLUDED` table), if +the spec describes a route the router doesn't have, or if the documented query parameters +differ from `DISCOVERY_PARAMS` and `RESOLVE_PARAMS` in `registry.js`. So when you change a +route or a parameter, change `openapi.yaml` with it. ## Configuration -### Registry Configuration - -```javascript -{ - code: 'main', // Unique registry identifier - name: 'Main Registry', // Display name - address: 'https://...', // Registry URL - authority: 'HL7', // Managing authority - servers: [...] // Array of server configs -} -``` - -### Server Configuration - -```javascript -{ - code: 'tx1', // Unique server identifier - name: 'TX Server', // Display name - address: 'https://...', // Base server URL - accessInfo: '...', // Access information - authCSList: [ // Authoritative code systems - 'http://loinc.org*', // Supports wildcards - 'http://snomed.info/sct' - ], - authVSList: [...], // Authoritative value sets - usageList: ['public'], // Usage tags - versions: [...] // Array of version configs -} -``` - -### Version Configuration +In the server config, under `modules.registry`: -```javascript -{ - version: '4.0.1', // FHIR version - address: 'https://...' // Version-specific endpoint +```json +"registry": { + "enabled": true, + "masterUrl": "https://fhir.github.io/ig-registry/tx-servers.json", + "crawlInterval": 30, + "timeout": 30000, + "userAgent": "YourServer/1.0", + "apiKeys": {} } ``` -## Authoritative Designations +| Setting | | +|---|---| +| `masterUrl` | The ecosystem's master registration file. Defaults to the HL7 one. | +| `crawlInterval` | Minutes between scans. 0 or absent: no scanning. | +| `timeout` | Per-request timeout for scanning, in milliseconds. | +| `userAgent` | The User-Agent the scanner sends. | +| `apiKeys` | API keys for servers that need one, by server code. | -Servers can be marked as authoritative for specific code systems or value sets. This affects ranking: +## Scanning -- Authoritative servers are always ranked first -- Wildcards are supported (e.g., `http://loinc.org*`) -- Non-authoritative servers are still returned but ranked lower -- Language specific claims (`languages`: BCP-47 tag -> mask list) make a server authoritative for - requests in that language only; matched entries rank ahead of language independent claims -- `exclusions` hides matching code systems/value sets from the ecosystem entirely (never - authoritative, never a candidate) +Each scan reads the master registration file, each registry it lists, and then, for each +server endpoint: -## Testing - -Run the test suite: - -```bash -npm test -``` - -Run with Jest (if installed): - -```bash -npm run test:jest -``` - -## Data Persistence - -The crawler saves and loads its state in [data]/registry-data.json - -## Development - -Start development server with auto-reload: - -```bash -npm run dev -``` +* `/metadata` (the CapabilityStatement, for the software name and version) +* `/metadata?mode=terminology` (the TerminologyCapabilities, for the code systems and + versions it hosts) +* `/ValueSet?_elements=url,version` (the value sets it hosts) -## Security Models +An endpoint that fails keeps the content found by its last successful scan, and reports the +error. The scanner refuses to fetch from private or loopback addresses (SSRF protection). -The crawler detects the following security models: +The results are saved to `[data]/registry/registry-data.json` after each scan, and loaded at +startup, so the registry can answer immediately after a restart. -- `open`: No authentication required -- `password`: Basic authentication -- `token`: Token-based authentication -- `oauth`: OAuth 2.0 -- `smart`: SMART on FHIR -- `cert`: Certificate-based authentication +## Code -## License +| File | | +|---|---| +| `registry.js` | The module: routes, HTML pages, scan scheduling | +| `api.js` | Discovery and resolution | +| `crawler.js` | Scanning | +| `model.js` | The data model, and mask and version matching | +| `openapi.yaml`, `openapi.js` | The API description | -BSD-3-Clause \ No newline at end of file +Tests are in `tests/registry`. diff --git a/registry/registry-template.html b/registry/registry-template.html index 18229544..8e0e7c72 100644 --- a/registry/registry-template.html +++ b/registry/registry-template.html @@ -9,6 +9,7 @@ + @@ -60,6 +61,7 @@ Registry Home  |  Resolve  |  Crawler Log  |  + API diff --git a/registry/registry.js b/registry/registry.js index 998be128..fd404599 100644 --- a/registry/registry.js +++ b/registry/registry.js @@ -9,6 +9,14 @@ const Logger = require('../library/logger'); const regLog = Logger.getInstance().child({ module: 'registry' }); const folders = require('../library/folder-setup'); const escape = require('escape-html'); +const registryOpenApi = require('./openapi'); + +// The query parameters of the public API. These are the contract published in openapi.yaml, +// and tests/registry/openapi.test.js checks that the two agree - so change both together. +// Parameters are read leniently (a repeated parameter takes its first value, and unknown +// parameters are ignored) because existing ecosystem clients rely on that. +const DISCOVERY_PARAMS = ['registry', 'server', 'fhirVersion', 'url', 'authoritativeOnly', 'language']; +const RESOLVE_PARAMS = ['fhirVersion', 'url', 'version', 'valueSet', 'authoritativeOnly', 'language', 'usage']; class RegistryModule { constructor(stats) { @@ -210,6 +218,9 @@ class RegistryModule { // Content Security Policy res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"); + // RFC 8631: where to find the machine-readable description of this API + res.setHeader('Link', `<${req.baseUrl}/openapi.json>; rel="service-desc", <${req.baseUrl}/openapi>; rel="service-doc"`); + next(); }); } @@ -230,6 +241,35 @@ class RegistryModule { this.router.get('/', this.handleMainPage.bind(this)); this.router.get('/resolve', this.handleResolveEndpoint.bind(this)); this.router.get('/log', this.handleLogEndpoint.bind(this)); + + // OpenAPI description of this API: /openapi.json, /openapi.yaml, and /openapi (an HTML + // reference for browsers, the JSON otherwise) + this.router.get('/openapi.json', (req, res) => { + res.json(registryOpenApi.getSpec()); + }); + this.router.get('/openapi.yaml', (req, res) => { + res.setHeader('Content-Type', 'application/yaml'); + res.send(registryOpenApi.getYaml()); + }); + this.router.get('/openapi', (req, res) => { + const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html'); + if (!acceptsHtml) { + res.json(registryOpenApi.getSpec()); + return; + } + try { + if (!htmlServer.hasTemplate('registry')) { + htmlServer.loadTemplate('registry', path.join(__dirname, 'registry-template.html')); + } + const html = htmlServer.renderPage('registry', 'Terminology Server Registry API', + registryOpenApi.renderHtml(), this.api ? this.api.getStatistics() : {}); + res.setHeader('Content-Type', 'text/html'); + res.send(html); + } catch (error) { + this.logger.error('Error rendering OpenAPI page:', error); + res.status(500).send(`

Error

${escape(error.message)}

`); + } + }); } /** @@ -357,21 +397,23 @@ class RegistryModule { const acceptsHtml = req.headers.accept && req.headers.accept.includes('text/html'); if (!acceptsHtml) { - // Return JSON overview - return res.json({ - name: 'FHIR Terminology Server Registry', - description: 'Registry and discovery service for FHIR terminology servers', - endpoints: { - status: '/registry/api/status', - statistics: '/registry/api/stats', - registries: '/registry/api/registries', - queryCodeSystem: '/registry/api/query/codesystem', - queryValueSet: '/registry/api/query/valueset', - bestServer: '/registry/api/best-server/{type}', - errors: '/registry/api/errors' - }, - documentation: 'https://github.com/your-org/fhir-registry' - }); + // The ecosystem Discovery API + const params = this._normalizeQueryParams(req.query); + if (params.url && !this._isValidUrl(params.url.split('|')[0])) { + return res.status(400).json({error: 'Invalid code system URL format'}); + } + try { + const filters = {}; + for (const name of DISCOVERY_PARAMS) { + if (params[name]) { + filters[name] = params[name]; + } + } + return res.json(this.api.discover(filters)); + } catch (error) { + this.logger.error('Error in discovery:', error); + return res.status(500).json({error: error.message}); + } } // Render HTML page @@ -1086,10 +1128,6 @@ class RegistryModule { this.logger.info(`Resolved CodeSystem ${url} for FHIR ${fhirVersion} (usage=${usage}, language=${language || 'none'}): ${matches}`); } - // If only authoritative servers are requested, filter results - if (authoritativeOnly === 'true' && result) { - result.candidates = []; - } if (acceptsHtml) { try { const startTime = Date.now(); @@ -1456,4 +1494,7 @@ class RegistryModule { } } +RegistryModule.DISCOVERY_PARAMS = DISCOVERY_PARAMS; +RegistryModule.RESOLVE_PARAMS = RESOLVE_PARAMS; + module.exports = RegistryModule; \ No newline at end of file From eba4ded98dcb2f249233dc0884ea8ced2cab8363 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 5 Oct 2026 18:04:54 +1300 Subject: [PATCH 04/16] set up openAPI for testing module, and fix various related API issues --- testing/openapi-schemas.config.js | 58 + testing/openapi-schemas.json | 1920 +++++++++++++++++++++++++++++ testing/openapi.js | 15 + testing/openapi.yaml | 273 ++++ testing/readme.md | 44 +- testing/search.js | 2 +- testing/testing-template.html | 2 + testing/testing.js | 24 + 8 files changed, 2336 insertions(+), 2 deletions(-) create mode 100644 testing/openapi-schemas.config.js create mode 100644 testing/openapi-schemas.json create mode 100644 testing/openapi.js create mode 100644 testing/openapi.yaml diff --git a/testing/openapi-schemas.config.js b/testing/openapi-schemas.config.js new file mode 100644 index 00000000..b264ecf1 --- /dev/null +++ b/testing/openapi-schemas.config.js @@ -0,0 +1,58 @@ +// What utilities/generate-openapi-schemas.js generates for the testing module's OpenAPI +// description (into openapi-schemas.json, which the loader merges into openapi.yaml's +// components). Regenerate after changing this: +// +// node utilities/generate-openapi-schemas.js testing +// +// tests/testing/openapi.test.js fails if the generated file is out of date, and checks that +// the overlay requires everything validateReport() in testing.js requires. + +const ref = (name) => ({ $ref: `#/components/schemas/${name}` }); + +module.exports = { + package: 'hl7.fhir.r5.core#5.0.0', + roots: ['TestReport', 'OperationOutcome', 'Bundle'], + + // reports may not contain resources (validateReport rejects them too) + prohibit: ['contained'], + + overlay: { + TestReport: { + description: 'A TestReport, as this server accepts it: an R5 TestReport (or an R4 one - see testScript), ' + + 'with name, tester, issued and at least one participant, and no contained resources.', + required: ['name', 'tester', 'issued', 'participant'], + properties: { + participant: { minItems: 1 }, + testScript: { + $replace: true, + description: 'The TestScript that was run: a canonical (R5), or a Reference (R4)', + anyOf: [{ type: 'string' }, ref('Reference')] + } + } + }, + + // the search response: a Bundle of TestReports, plus an OperationOutcome entry + // (search.mode = outcome) when unknown parameters were ignored + TestReportSearchBundle: { + description: 'A searchset Bundle of TestReports', + allOf: [ + ref('Bundle'), + { + type: 'object', + properties: { + type: { const: 'searchset' }, + entry: { + type: 'array', + items: { + type: 'object', + properties: { + resource: { anyOf: [ref('TestReport'), ref('OperationOutcome')] } + } + } + } + } + } + ] + } + } +}; diff --git a/testing/openapi-schemas.json b/testing/openapi-schemas.json new file mode 100644 index 00000000..309f7a6f --- /dev/null +++ b/testing/openapi-schemas.json @@ -0,0 +1,1920 @@ +{ + "generatedFrom": "hl7.fhir.r5.core#5.0.0", + "schemas": { + "AnyResource": { + "type": "object", + "description": "A FHIR resource of any type. Not described further here.", + "properties": { + "resourceType": { + "type": "string" + } + }, + "required": [ + "resourceType" + ], + "additionalProperties": true + }, + "Bundle": { + "type": "object", + "description": "A container for a collection of resources.", + "properties": { + "resourceType": { + "const": "Bundle" + }, + "id": { + "type": "string", + "description": "Logical id of this artifact" + }, + "meta": { + "$ref": "#/components/schemas/Meta", + "description": "Metadata about the resource" + }, + "implicitRules": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "A set of rules under which this content was created" + }, + "_implicitRules": { + "$ref": "#/components/schemas/Element" + }, + "language": { + "x-fhir-type": "code", + "type": "string", + "pattern": "^([^\\s]+( [^\\s]+)*)$", + "description": "Language of the resource content" + }, + "_language": { + "$ref": "#/components/schemas/Element" + }, + "identifier": { + "$ref": "#/components/schemas/Identifier", + "description": "Persistent identifier for the bundle" + }, + "type": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "document", + "message", + "transaction", + "transaction-response", + "batch", + "batch-response", + "history", + "searchset", + "collection", + "subscription-notification" + ], + "description": "document | message | transaction | transaction-response | batch | batch-response | history | searchset | collection | subscription-notification" + }, + "_type": { + "$ref": "#/components/schemas/Element" + }, + "timestamp": { + "x-fhir-type": "instant", + "type": "string", + "pattern": "^(([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))$", + "description": "When the bundle was assembled" + }, + "_timestamp": { + "$ref": "#/components/schemas/Element" + }, + "total": { + "x-fhir-type": "unsignedInt", + "type": "integer", + "minimum": 0, + "description": "If search, the total number of matches" + }, + "_total": { + "$ref": "#/components/schemas/Element" + }, + "link": { + "type": "array", + "description": "Links related to this Bundle", + "items": { + "$ref": "#/components/schemas/Bundle_Link" + } + }, + "entry": { + "type": "array", + "description": "Entry in the bundle - will have a resource or information", + "items": { + "$ref": "#/components/schemas/Bundle_Entry" + } + }, + "signature": { + "$ref": "#/components/schemas/Signature", + "description": "Digital Signature" + }, + "issues": { + "$ref": "#/components/schemas/AnyResource", + "description": "Issues with the Bundle" + } + }, + "required": [ + "resourceType", + "type" + ], + "additionalProperties": false + }, + "Bundle_Entry": { + "type": "object", + "description": "An entry in a bundle resource - will either contain a resource or information about a resource (transactions and history only).", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "link": { + "type": "array", + "description": "Links related to this entry", + "items": { + "$ref": "#/components/schemas/Bundle_Link" + } + }, + "fullUrl": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "URI for resource (e.g. the absolute URL server address, URI for UUID/OID, etc.)" + }, + "_fullUrl": { + "$ref": "#/components/schemas/Element" + }, + "resource": { + "$ref": "#/components/schemas/AnyResource", + "description": "A resource in the bundle" + }, + "search": { + "$ref": "#/components/schemas/Bundle_Entry_Search", + "description": "Search related information" + }, + "request": { + "$ref": "#/components/schemas/Bundle_Entry_Request", + "description": "Additional execution information (transaction/batch/history)" + }, + "response": { + "$ref": "#/components/schemas/Bundle_Entry_Response", + "description": "Results of execution (transaction/batch/history)" + } + }, + "additionalProperties": false + }, + "Bundle_Entry_Request": { + "type": "object", + "description": "Additional information about how this entry should be processed as part of a transaction or batch. For history, it shows how the entry was processed to create the version contained in the entry.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "method": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "GET", + "HEAD", + "POST", + "PUT", + "DELETE", + "PATCH" + ], + "description": "GET | HEAD | POST | PUT | DELETE | PATCH" + }, + "_method": { + "$ref": "#/components/schemas/Element" + }, + "url": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "URL for HTTP equivalent of this entry" + }, + "_url": { + "$ref": "#/components/schemas/Element" + }, + "ifNoneMatch": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "For managing cache validation" + }, + "_ifNoneMatch": { + "$ref": "#/components/schemas/Element" + }, + "ifModifiedSince": { + "x-fhir-type": "instant", + "type": "string", + "pattern": "^(([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))$", + "description": "For managing cache currency" + }, + "_ifModifiedSince": { + "$ref": "#/components/schemas/Element" + }, + "ifMatch": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "For managing update contention" + }, + "_ifMatch": { + "$ref": "#/components/schemas/Element" + }, + "ifNoneExist": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "For conditional creates" + }, + "_ifNoneExist": { + "$ref": "#/components/schemas/Element" + } + }, + "required": [ + "method", + "url" + ], + "additionalProperties": false + }, + "Bundle_Entry_Response": { + "type": "object", + "description": "Indicates the results of processing the corresponding 'request' entry in the batch or transaction being responded to or what the results of an operation where when returning history.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "status": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Status response code (text optional)" + }, + "_status": { + "$ref": "#/components/schemas/Element" + }, + "location": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "The location (if the operation returns a location)" + }, + "_location": { + "$ref": "#/components/schemas/Element" + }, + "etag": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "The Etag for the resource (if relevant)" + }, + "_etag": { + "$ref": "#/components/schemas/Element" + }, + "lastModified": { + "x-fhir-type": "instant", + "type": "string", + "pattern": "^(([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))$", + "description": "Server's date time modified" + }, + "_lastModified": { + "$ref": "#/components/schemas/Element" + }, + "outcome": { + "$ref": "#/components/schemas/AnyResource", + "description": "OperationOutcome with hints and warnings (for batch/transaction)" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "Bundle_Entry_Search": { + "type": "object", + "description": "Information about the search process that lead to the creation of this entry.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "mode": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "match", + "include", + "outcome" + ], + "description": "match | include - why this is in the result set" + }, + "_mode": { + "$ref": "#/components/schemas/Element" + }, + "score": { + "x-fhir-type": "decimal", + "type": "number", + "description": "Search ranking (between 0 and 1)" + }, + "_score": { + "$ref": "#/components/schemas/Element" + } + }, + "additionalProperties": false + }, + "Bundle_Link": { + "type": "object", + "description": "A series of links that provide context to this bundle.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "relation": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "about", + "acl", + "alternate", + "amphtml", + "appendix", + "apple-touch-icon", + "apple-touch-startup-image", + "archives", + "author", + "blocked-by", + "bookmark", + "canonical", + "chapter", + "cite-as", + "collection", + "contents", + "convertedFrom", + "copyright", + "create-form", + "current", + "describedby", + "describes", + "disclosure", + "dns-prefetch", + "duplicate", + "edit", + "edit-form", + "edit-media", + "enclosure", + "external", + "first", + "glossary", + "help", + "hosts", + "hub", + "icon", + "index", + "intervalAfter", + "intervalBefore", + "intervalContains", + "intervalDisjoint", + "intervalDuring", + "intervalEquals", + "intervalFinishedBy", + "intervalFinishes", + "intervalIn", + "intervalMeets", + "intervalMetBy", + "intervalOverlappedBy", + "intervalOverlaps", + "intervalStartedBy", + "intervalStarts", + "item", + "last", + "latest-version", + "license", + "linkset", + "lrdd", + "manifest", + "mask-icon", + "media-feed", + "memento", + "micropub", + "modulepreload", + "monitor", + "monitor-group", + "next", + "next-archive", + "nofollow", + "noopener", + "noreferrer", + "opener", + "openid2.local_id", + "openid2.provider", + "original", + "P3Pv1", + "payment", + "pingback", + "preconnect", + "predecessor-version", + "prefetch", + "preload", + "prerender", + "prev", + "preview", + "previous", + "prev-archive", + "privacy-policy", + "profile", + "publication", + "related", + "restconf", + "replies", + "ruleinput", + "search", + "section", + "self", + "service", + "service-desc", + "service-doc", + "service-meta", + "sponsored", + "start", + "status", + "stylesheet", + "subsection", + "successor-version", + "sunset", + "tag", + "terms-of-service", + "timegate", + "timemap", + "type", + "ugc", + "up", + "version-history", + "via", + "webmention", + "working-copy", + "working-copy-of" + ], + "description": "See http://www.iana.org/assignments/link-relations/link-relations.xhtml#link-relations-1" + }, + "_relation": { + "$ref": "#/components/schemas/Element" + }, + "url": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "Reference details for the link" + }, + "_url": { + "$ref": "#/components/schemas/Element" + } + }, + "required": [ + "relation", + "url" + ], + "additionalProperties": false + }, + "CodeableConcept": { + "type": "object", + "description": "CodeableConcept Type: A concept that may be defined by a formal reference to a terminology or ontology or may be provided by text.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "coding": { + "type": "array", + "description": "Code defined by a terminology system", + "items": { + "$ref": "#/components/schemas/Coding" + } + }, + "text": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Plain text representation of the concept" + }, + "_text": { + "$ref": "#/components/schemas/Element" + } + }, + "additionalProperties": false + }, + "Coding": { + "type": "object", + "description": "Coding Type: A reference to a code defined by a terminology system.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "system": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "Identity of the terminology system" + }, + "_system": { + "$ref": "#/components/schemas/Element" + }, + "version": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Version of the system - if relevant" + }, + "_version": { + "$ref": "#/components/schemas/Element" + }, + "code": { + "x-fhir-type": "code", + "type": "string", + "pattern": "^([^\\s]+( [^\\s]+)*)$", + "description": "Symbol in syntax defined by the system" + }, + "_code": { + "$ref": "#/components/schemas/Element" + }, + "display": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Representation defined by the system" + }, + "_display": { + "$ref": "#/components/schemas/Element" + }, + "userSelected": { + "x-fhir-type": "boolean", + "type": "boolean", + "description": "If this coding was chosen directly by the user" + }, + "_userSelected": { + "$ref": "#/components/schemas/Element" + } + }, + "additionalProperties": false + }, + "Element": { + "type": "object", + "description": "Element Type: Base definition for all elements in a resource.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + } + }, + "additionalProperties": false + }, + "Extension": { + "type": "object", + "description": "An extension. The value (value[x]) can be any FHIR datatype, and is not described further here.", + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string", + "description": "The extension's definition" + }, + "extension": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Extension" + } + } + }, + "patternProperties": { + "^_?value[A-Z][A-Za-z0-9]*$": { + "description": "The value (value[x]), and its _value[x] sibling for a primitive" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "Identifier": { + "type": "object", + "description": "Identifier Type: An identifier - identifies some entity uniquely and unambiguously. Typically this is used for business identifiers.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "use": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "usual", + "official", + "temp", + "secondary", + "old" + ], + "description": "usual | official | temp | secondary | old (If known)" + }, + "_use": { + "$ref": "#/components/schemas/Element" + }, + "type": { + "$ref": "#/components/schemas/CodeableConcept", + "description": "Description of identifier" + }, + "system": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "The namespace for the identifier value" + }, + "_system": { + "$ref": "#/components/schemas/Element" + }, + "value": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "The value that is unique" + }, + "_value": { + "$ref": "#/components/schemas/Element" + }, + "period": { + "$ref": "#/components/schemas/Period", + "description": "Time period when id is/was valid for use" + }, + "assigner": { + "$ref": "#/components/schemas/Reference", + "description": "Organization that issued id (may be just text)" + } + }, + "additionalProperties": false + }, + "Meta": { + "type": "object", + "description": "Meta Type: The metadata about a resource. This is content in the resource that is maintained by the infrastructure. Changes to the content might not always be associated with version changes to the resource.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "versionId": { + "x-fhir-type": "id", + "type": "string", + "pattern": "^([A-Za-z0-9\\-\\.]{1,64})$", + "description": "Version specific identifier" + }, + "_versionId": { + "$ref": "#/components/schemas/Element" + }, + "lastUpdated": { + "x-fhir-type": "instant", + "type": "string", + "pattern": "^(([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))$", + "description": "When the resource version last changed" + }, + "_lastUpdated": { + "$ref": "#/components/schemas/Element" + }, + "source": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "Identifies where the resource comes from" + }, + "_source": { + "$ref": "#/components/schemas/Element" + }, + "profile": { + "type": "array", + "description": "Profiles this resource claims to conform to", + "items": { + "anyOf": [ + { + "x-fhir-type": "canonical", + "type": "string", + "pattern": "^(\\S*)$" + }, + { + "type": "null" + } + ] + } + }, + "_profile": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Element" + }, + { + "type": "null" + } + ] + } + }, + "security": { + "type": "array", + "description": "Security Labels applied to this resource", + "items": { + "$ref": "#/components/schemas/Coding" + } + }, + "tag": { + "type": "array", + "description": "Tags applied to this resource", + "items": { + "$ref": "#/components/schemas/Coding" + } + } + }, + "additionalProperties": false + }, + "Narrative": { + "type": "object", + "description": "Narrative Type: A human-readable summary of the resource conveying the essential clinical and business information for the resource.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "status": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "generated", + "extensions", + "additional", + "empty" + ], + "description": "generated | extensions | additional | empty" + }, + "_status": { + "$ref": "#/components/schemas/Element" + }, + "div": { + "x-fhir-type": "xhtml", + "type": "string", + "description": "Limited xhtml content" + }, + "_div": { + "$ref": "#/components/schemas/Element" + } + }, + "required": [ + "status", + "div" + ], + "additionalProperties": false + }, + "OperationOutcome": { + "type": "object", + "description": "A collection of error, warning, or information messages that result from a system action.", + "properties": { + "resourceType": { + "const": "OperationOutcome" + }, + "id": { + "type": "string", + "description": "Logical id of this artifact" + }, + "meta": { + "$ref": "#/components/schemas/Meta", + "description": "Metadata about the resource" + }, + "implicitRules": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "A set of rules under which this content was created" + }, + "_implicitRules": { + "$ref": "#/components/schemas/Element" + }, + "language": { + "x-fhir-type": "code", + "type": "string", + "pattern": "^([^\\s]+( [^\\s]+)*)$", + "description": "Language of the resource content" + }, + "_language": { + "$ref": "#/components/schemas/Element" + }, + "text": { + "$ref": "#/components/schemas/Narrative", + "description": "Text summary of the resource, for human interpretation" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "issue": { + "type": "array", + "description": "A single issue associated with the action", + "items": { + "$ref": "#/components/schemas/OperationOutcome_Issue" + } + } + }, + "required": [ + "resourceType", + "issue" + ], + "additionalProperties": false + }, + "OperationOutcome_Issue": { + "type": "object", + "description": "An error, warning, or information message that results from a system action.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "severity": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "fatal", + "error", + "warning", + "information", + "success" + ], + "description": "fatal | error | warning | information | success" + }, + "_severity": { + "$ref": "#/components/schemas/Element" + }, + "code": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "invalid", + "structure", + "required", + "value", + "invariant", + "security", + "login", + "unknown", + "expired", + "forbidden", + "suppressed", + "processing", + "not-supported", + "duplicate", + "multiple-matches", + "not-found", + "deleted", + "too-long", + "code-invalid", + "extension", + "too-costly", + "business-rule", + "conflict", + "limited-filter", + "transient", + "lock-error", + "no-store", + "exception", + "timeout", + "incomplete", + "throttled", + "informational", + "success" + ], + "description": "Error or warning code" + }, + "_code": { + "$ref": "#/components/schemas/Element" + }, + "details": { + "$ref": "#/components/schemas/CodeableConcept", + "description": "Additional details about the error" + }, + "diagnostics": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Additional diagnostic information about the issue" + }, + "_diagnostics": { + "$ref": "#/components/schemas/Element" + }, + "location": { + "type": "array", + "description": "Deprecated: Path of element(s) related to issue", + "items": { + "anyOf": [ + { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$" + }, + { + "type": "null" + } + ] + } + }, + "_location": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Element" + }, + { + "type": "null" + } + ] + } + }, + "expression": { + "type": "array", + "description": "FHIRPath of element(s) related to issue", + "items": { + "anyOf": [ + { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$" + }, + { + "type": "null" + } + ] + } + }, + "_expression": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Element" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "severity", + "code" + ], + "additionalProperties": false + }, + "Period": { + "type": "object", + "description": "Period Type: A time period defined by a start and end date and optionally time.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "start": { + "x-fhir-type": "dateTime", + "type": "string", + "pattern": "^(([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?)?)?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)?)?)?)$", + "description": "Starting time with inclusive boundary" + }, + "_start": { + "$ref": "#/components/schemas/Element" + }, + "end": { + "x-fhir-type": "dateTime", + "type": "string", + "pattern": "^(([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?)?)?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)?)?)?)$", + "description": "End time with inclusive boundary, if not ongoing" + }, + "_end": { + "$ref": "#/components/schemas/Element" + } + }, + "additionalProperties": false + }, + "Reference": { + "type": "object", + "description": "Reference Type: A reference from one resource to another.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "reference": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Literal reference, Relative, internal or absolute URL" + }, + "_reference": { + "$ref": "#/components/schemas/Element" + }, + "type": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "Type the reference refers to (e.g. \"Patient\") - must be a resource in resources" + }, + "_type": { + "$ref": "#/components/schemas/Element" + }, + "identifier": { + "$ref": "#/components/schemas/Identifier", + "description": "Logical reference, when literal reference is not known" + }, + "display": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Text alternative for the resource" + }, + "_display": { + "$ref": "#/components/schemas/Element" + } + }, + "additionalProperties": false + }, + "Signature": { + "type": "object", + "description": "Signature Type: A signature along with supporting context. The signature may be a digital signature that is cryptographic in nature, or some other signature acceptable to the domain. This other signature may be as simple as a graphical image representing a hand-written signature, or a signature ceremony Different signature approaches have different utilities.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "type": { + "type": "array", + "description": "Indication of the reason the entity signed the object(s)", + "items": { + "$ref": "#/components/schemas/Coding" + } + }, + "when": { + "x-fhir-type": "instant", + "type": "string", + "pattern": "^(([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))$", + "description": "When the signature was created" + }, + "_when": { + "$ref": "#/components/schemas/Element" + }, + "who": { + "$ref": "#/components/schemas/Reference", + "description": "Who signed" + }, + "onBehalfOf": { + "$ref": "#/components/schemas/Reference", + "description": "The party represented" + }, + "targetFormat": { + "x-fhir-type": "code", + "type": "string", + "pattern": "^([^\\s]+( [^\\s]+)*)$", + "description": "The technical format of the signed resources" + }, + "_targetFormat": { + "$ref": "#/components/schemas/Element" + }, + "sigFormat": { + "x-fhir-type": "code", + "type": "string", + "pattern": "^([^\\s]+( [^\\s]+)*)$", + "description": "The technical format of the signature" + }, + "_sigFormat": { + "$ref": "#/components/schemas/Element" + }, + "data": { + "x-fhir-type": "base64Binary", + "type": "string", + "pattern": "^((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?)$", + "description": "The actual signature content (XML DigSig. JWS, picture, etc.)" + }, + "_data": { + "$ref": "#/components/schemas/Element" + } + }, + "additionalProperties": false + }, + "TestReport": { + "type": "object", + "description": "A TestReport, as this server accepts it: an R5 TestReport (or an R4 one - see testScript), with name, tester, issued and at least one participant, and no contained resources.", + "properties": { + "resourceType": { + "const": "TestReport" + }, + "id": { + "type": "string", + "description": "Logical id of this artifact" + }, + "meta": { + "$ref": "#/components/schemas/Meta", + "description": "Metadata about the resource" + }, + "implicitRules": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "A set of rules under which this content was created" + }, + "_implicitRules": { + "$ref": "#/components/schemas/Element" + }, + "language": { + "x-fhir-type": "code", + "type": "string", + "pattern": "^([^\\s]+( [^\\s]+)*)$", + "description": "Language of the resource content" + }, + "_language": { + "$ref": "#/components/schemas/Element" + }, + "text": { + "$ref": "#/components/schemas/Narrative", + "description": "Text summary of the resource, for human interpretation" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "identifier": { + "$ref": "#/components/schemas/Identifier", + "description": "External identifier" + }, + "name": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Informal name of the executed TestReport" + }, + "_name": { + "$ref": "#/components/schemas/Element" + }, + "status": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "completed", + "in-progress", + "waiting", + "stopped", + "entered-in-error" + ], + "description": "completed | in-progress | waiting | stopped | entered-in-error" + }, + "_status": { + "$ref": "#/components/schemas/Element" + }, + "testScript": { + "description": "The TestScript that was run: a canonical (R5), or a Reference (R4)", + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/Reference" + } + ] + }, + "_testScript": { + "$ref": "#/components/schemas/Element" + }, + "result": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "pass", + "fail", + "pending" + ], + "description": "pass | fail | pending" + }, + "_result": { + "$ref": "#/components/schemas/Element" + }, + "score": { + "x-fhir-type": "decimal", + "type": "number", + "description": "The final score (percentage of tests passed) resulting from the execution of the TestScript" + }, + "_score": { + "$ref": "#/components/schemas/Element" + }, + "tester": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Name of the tester producing this report (Organization or individual)" + }, + "_tester": { + "$ref": "#/components/schemas/Element" + }, + "issued": { + "x-fhir-type": "dateTime", + "type": "string", + "pattern": "^(([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]{1,9})?)?)?(Z|(\\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)?)?)?)$", + "description": "When the TestScript was executed and this TestReport was generated" + }, + "_issued": { + "$ref": "#/components/schemas/Element" + }, + "participant": { + "type": "array", + "description": "A participant in the test execution, either the execution engine, a client, or a server", + "items": { + "$ref": "#/components/schemas/TestReport_Participant" + }, + "minItems": 1 + }, + "setup": { + "$ref": "#/components/schemas/TestReport_Setup", + "description": "The results of the series of required setup operations before the tests were executed" + }, + "test": { + "type": "array", + "description": "A test executed from the test script", + "items": { + "$ref": "#/components/schemas/TestReport_Test" + } + }, + "teardown": { + "$ref": "#/components/schemas/TestReport_Teardown", + "description": "The results of running the series of required clean up steps" + } + }, + "required": [ + "resourceType", + "status", + "testScript", + "result", + "name", + "tester", + "issued", + "participant" + ], + "additionalProperties": false + }, + "TestReport_Participant": { + "type": "object", + "description": "A participant in the test execution, either the execution engine, a client, or a server.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "type": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "test-engine", + "client", + "server" + ], + "description": "test-engine | client | server" + }, + "_type": { + "$ref": "#/components/schemas/Element" + }, + "uri": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "The uri of the participant. An absolute URL is preferred" + }, + "_uri": { + "$ref": "#/components/schemas/Element" + }, + "display": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "The display name of the participant" + }, + "_display": { + "$ref": "#/components/schemas/Element" + } + }, + "required": [ + "type", + "uri" + ], + "additionalProperties": false + }, + "TestReport_Setup": { + "type": "object", + "description": "The results of the series of required setup operations before the tests were executed.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "action": { + "type": "array", + "description": "A setup operation or assert that was executed", + "items": { + "$ref": "#/components/schemas/TestReport_Setup_Action" + } + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "TestReport_Setup_Action": { + "type": "object", + "description": "Action would contain either an operation or an assertion.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "operation": { + "$ref": "#/components/schemas/TestReport_Setup_Action_Operation", + "description": "The operation to perform" + }, + "assert": { + "$ref": "#/components/schemas/TestReport_Setup_Action_Assert", + "description": "The assertion to perform" + } + }, + "additionalProperties": false + }, + "TestReport_Setup_Action_Assert": { + "type": "object", + "description": "The results of the assertion performed on the previous operations.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "result": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "pass", + "skip", + "fail", + "warning", + "error" + ], + "description": "pass | skip | fail | warning | error" + }, + "_result": { + "$ref": "#/components/schemas/Element" + }, + "message": { + "x-fhir-type": "markdown", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "A message associated with the result" + }, + "_message": { + "$ref": "#/components/schemas/Element" + }, + "detail": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "A link to further details on the result" + }, + "_detail": { + "$ref": "#/components/schemas/Element" + }, + "requirement": { + "type": "array", + "description": "Links or references to the testing requirements", + "items": { + "$ref": "#/components/schemas/TestReport_Setup_Action_Assert_Requirement" + } + } + }, + "required": [ + "result" + ], + "additionalProperties": false + }, + "TestReport_Setup_Action_Assert_Requirement": { + "type": "object", + "description": "Links or references providing traceability to the testing requirements for this assert.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "linkUri": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "Link or reference to the testing requirement" + }, + "_linkUri": { + "$ref": "#/components/schemas/Element" + }, + "linkCanonical": { + "x-fhir-type": "canonical", + "type": "string", + "pattern": "^(\\S*)$", + "description": "Link or reference to the testing requirement" + }, + "_linkCanonical": { + "$ref": "#/components/schemas/Element" + } + }, + "additionalProperties": false + }, + "TestReport_Setup_Action_Operation": { + "type": "object", + "description": "The operation performed.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "result": { + "x-fhir-type": "code", + "type": "string", + "enum": [ + "pass", + "skip", + "fail", + "warning", + "error" + ], + "description": "pass | skip | fail | warning | error" + }, + "_result": { + "$ref": "#/components/schemas/Element" + }, + "message": { + "x-fhir-type": "markdown", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "A message associated with the result" + }, + "_message": { + "$ref": "#/components/schemas/Element" + }, + "detail": { + "x-fhir-type": "uri", + "type": "string", + "pattern": "^(\\S*)$", + "description": "A link to further details on the result" + }, + "_detail": { + "$ref": "#/components/schemas/Element" + } + }, + "required": [ + "result" + ], + "additionalProperties": false + }, + "TestReport_Teardown": { + "type": "object", + "description": "The results of the series of operations required to clean up after all the tests were executed (successfully or otherwise).", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "action": { + "type": "array", + "description": "One or more teardown operations performed", + "items": { + "$ref": "#/components/schemas/TestReport_Teardown_Action" + } + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "TestReport_Teardown_Action": { + "type": "object", + "description": "The teardown action will only contain an operation.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "operation": { + "$ref": "#/components/schemas/TestReport_Setup_Action_Operation", + "description": "The teardown operation performed" + } + }, + "required": [ + "operation" + ], + "additionalProperties": false + }, + "TestReport_Test": { + "type": "object", + "description": "A test executed from the test script.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "name": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Tracking/logging name of this test" + }, + "_name": { + "$ref": "#/components/schemas/Element" + }, + "description": { + "x-fhir-type": "string", + "type": "string", + "pattern": "^[\\s\\S]+$", + "description": "Tracking/reporting short description of the test" + }, + "_description": { + "$ref": "#/components/schemas/Element" + }, + "action": { + "type": "array", + "description": "A test operation or assert that was performed", + "items": { + "$ref": "#/components/schemas/TestReport_Test_Action" + } + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "TestReport_Test_Action": { + "type": "object", + "description": "Action would contain either an operation or an assertion.", + "properties": { + "id": { + "type": "string", + "description": "Unique id for inter-element referencing" + }, + "extension": { + "type": "array", + "description": "Additional content defined by implementations", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "modifierExtension": { + "type": "array", + "description": "Extensions that cannot be ignored even if unrecognized", + "items": { + "$ref": "#/components/schemas/Extension" + } + }, + "operation": { + "$ref": "#/components/schemas/TestReport_Setup_Action_Operation", + "description": "The operation performed" + }, + "assert": { + "$ref": "#/components/schemas/TestReport_Setup_Action_Assert", + "description": "The assertion performed" + } + }, + "additionalProperties": false + }, + "TestReportSearchBundle": { + "description": "A searchset Bundle of TestReports", + "allOf": [ + { + "$ref": "#/components/schemas/Bundle" + }, + { + "type": "object", + "properties": { + "type": { + "const": "searchset" + }, + "entry": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource": { + "anyOf": [ + { + "$ref": "#/components/schemas/TestReport" + }, + { + "$ref": "#/components/schemas/OperationOutcome" + } + ] + } + } + } + } + } + } + ] + } + } +} diff --git a/testing/openapi.js b/testing/openapi.js new file mode 100644 index 00000000..83ee703b --- /dev/null +++ b/testing/openapi.js @@ -0,0 +1,15 @@ +// +// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au) +// +// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause +// + +// The testing module's OpenAPI description: openapi.yaml (the paths, written by hand) plus +// openapi-schemas.json (the FHIR resource schemas, generated from the R5 StructureDefinitions +// by utilities/generate-openapi-schemas.js - see openapi-schemas.config.js). + +const path = require('path'); +const { createOpenApiDoc } = require('../library/openapi-doc'); + +module.exports = createOpenApiDoc(path.join(__dirname, 'openapi.yaml'), '/testing', + { schemasPath: path.join(__dirname, 'openapi-schemas.json') }); diff --git a/testing/openapi.yaml b/testing/openapi.yaml new file mode 100644 index 00000000..f741f1a6 --- /dev/null +++ b/testing/openapi.yaml @@ -0,0 +1,273 @@ +# OpenAPI description of the FHIRsmith testing module (/testing): a FHIR API for TestReports. +# +# The paths are maintained here by hand. The FHIR resource schemas (TestReport, +# OperationOutcome, Bundle, the datatypes they use, and TestReportSearchBundle) are generated +# from the R5 StructureDefinitions into openapi-schemas.json, and merged into +# components.schemas when the spec is served - see openapi-schemas.config.js. +# +# tests/testing/openapi.test.js keeps this honest: every route must be described here or +# explicitly excluded, the search parameters must be the ones search.js understands, and +# the TestReport schema must require everything validateReport() in testing.js requires. +# +# Administrative routes (DELETE, the login and admin pages) are deliberately not described. +# info.version is filled in from package.json when the spec is served. + +openapi: 3.1.0 +info: + title: FHIRsmith Test Reports + version: "0.0.0" + summary: A FHIR repository for TestReports + description: | + Receives FHIR TestReports - from TxTester, and from any other tool that produces them - + and makes them available for reading and searching. This is a small FHIR R5 server with + one resource type, TestReport, and three interactions: create, read and search. + + * **R4 and R5** TestReports are accepted alike (they differ only in `testScript`). + * **Reports are immutable**: the server assigns the id and `meta.lastUpdated`, drops + `meta.versionId`, and each POST is a new report, even of the same report. + * **Contained resources are not accepted.** + * The server checks only what the TestReport schema marks as required (and that + `issued` is a valid dateTime and `score` a number). The schema describes a valid + report; reports that don't conform to it in other ways may still be accepted, but + shouldn't be sent. + + **Content negotiation.** Read and search return an HTML page to a browser (an + `Accept` containing `text/html`); `_format=json` or `_format=html` overrides that. + license: + name: BSD-3-Clause + url: https://opensource.org/license/bsd-3-clause +externalDocs: + description: The TestReport resource (FHIR R5) + url: https://hl7.org/fhir/R5/testreport.html +servers: + - url: /testing + +paths: + /TestReport: + post: + tags: [TestReport] + operationId: createTestReport + summary: Submit a report + description: | + Stores the report and returns it with its new id and `meta.lastUpdated`. Reports + are limited in size (500 KB by default) and submissions are rate limited. + + If the server is configured with a token, every submission must carry it, in the + `Authorization` header by default (`Bearer `; the header is configurable). + security: + - {} + - submitToken: [] + parameters: + - name: Prefer + in: header + description: '`return=minimal` (no body), `return=representation` (the default), or `return=OperationOutcome`.' + schema: { type: string } + example: return=minimal + requestBody: + required: true + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/TestReport' } + application/json: + schema: { $ref: '#/components/schemas/TestReport' } + responses: + '201': + description: Stored. The body is the stored report, nothing, or an OperationOutcome, as `Prefer` asks. + headers: + Location: + description: The new report's URL + schema: { type: string, format: uri } + Last-Modified: + schema: { type: string } + content: + application/fhir+json: + schema: + anyOf: + - $ref: '#/components/schemas/TestReport' + - $ref: '#/components/schemas/OperationOutcome' + '400': { $ref: '#/components/responses/Invalid' } + '401': + description: A token is required and wasn't supplied, or is wrong + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/OperationOutcome' } + '413': + description: The report is too big + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/OperationOutcome' } + '415': + description: The body isn't JSON + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/OperationOutcome' } + '429': + description: Too many submissions; try again later + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/OperationOutcome' } + + get: + tags: [TestReport] + operationId: searchTestReports + summary: Search reports + description: | + Returns a searchset Bundle, newest received first unless `_sort` says otherwise. + `total` is always present. + + Comma separated values are ORed; repeated parameters are ANDed. The search + parameters aren't yet formally defined as SearchParameter resources; their + behaviour is as described here. Modifiers are written after the name + (`name:exact=...`). + + Unknown parameters are ignored, with a warning OperationOutcome entry + (`search.mode = outcome`) in the Bundle - unless the request has + `Prefer: handling=strict`, when they're a 400. Values that can't be understood (a + bad date or number) are always a 400. + parameters: + - name: _id + in: query + description: The report's id. + schema: { type: string } + - name: name + in: query + description: '`name` starts with this, case-insensitively. Modifiers: `:exact`, `:contains`, `:missing`.' + schema: { type: string } + example: tx-ecosystem + - name: tester + in: query + description: '`tester` starts with this, case-insensitively. Modifiers: `:exact`, `:contains`, `:missing`.' + schema: { type: string } + - name: status + in: query + description: '`status`, exactly (a `system|` prefix is ignored). Modifiers: `:not`, `:missing`.' + schema: { type: string } + example: completed + - name: result + in: query + description: '`result`, exactly. Modifiers: `:not`, `:missing`.' + schema: { type: string } + example: fail + - name: testscript + in: query + description: '`testScript` (the R5 canonical, or the R4 reference), exactly. Modifiers: `:below` (starts with), `:missing`.' + schema: { type: string } + - name: participant + in: query + description: 'The `uri` of any participant, exactly. Modifier: `:below` (starts with).' + schema: { type: string } + example: http://tx.fhir.org/r4 + - name: score + in: query + description: '`score`, with a prefix (`eq` `ne` `gt` `lt` `ge` `le` `sa` `eb` `ap`); `eq` has implicit precision (`90` means 89.5 to 90.5). Modifier: `:missing`.' + schema: { type: string } + example: ge90 + - name: issued + in: query + description: '`issued`, with a prefix; dates are ranges (`issued=2026-09` is all of September).' + schema: { type: string } + example: ge2026-09 + - name: _lastUpdated + in: query + description: When the report was received, with a prefix. + schema: { type: string } + - name: _sort + in: query + description: Any of the parameters above except `_id`, comma separated, `-` for descending. + schema: { type: string } + example: -issued + - name: _count + in: query + description: Page size; defaults to 50, at most 500. `0` returns just the total. + schema: { type: integer, minimum: 0, maximum: 500 } + - name: _offset + in: query + description: Where the page starts. The Bundle has first, previous, next and last links. + schema: { type: integer, minimum: 0 } + - name: _summary + in: query + description: '`count` returns just the total.' + schema: { type: string, enum: [count] } + - name: _format + in: query + description: '`json` or `html`, overriding the `Accept` header.' + schema: { type: string } + - name: _total + in: query + description: Accepted, and has no effect - the total is always returned. + schema: { type: string } + - name: _pretty + in: query + description: Accepted, and has no effect - responses are always indented. + schema: { type: string } + - name: Prefer + in: header + description: '`handling=strict` makes unknown parameters an error.' + schema: { type: string } + responses: + '200': + description: The matching reports + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/TestReportSearchBundle' } + text/html: + schema: { type: string } + '400': { $ref: '#/components/responses/Invalid' } + + /TestReport/{id}: + get: + tags: [TestReport] + operationId: readTestReport + summary: Read a report + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: The report, or a rendering of it for a browser + headers: + Last-Modified: + schema: { type: string } + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/TestReport' } + text/html: + schema: { type: string } + '404': + description: No such report + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/OperationOutcome' } + + /metadata: + get: + tags: [server] + operationId: capabilities + summary: The server's CapabilityStatement + description: | + Lists the interactions and search parameters. The search parameters have no + definitions (see the search operation for what they do). + responses: + '200': + description: A CapabilityStatement + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/AnyResource' } + +components: + securitySchemes: + submitToken: + type: http + scheme: bearer + description: | + Required for submitting reports if the server is configured with a token. Sent in the + `Authorization` header by default; a server can be configured to use another header. + + responses: + Invalid: + description: The request isn't acceptable - the issues say why + content: + application/fhir+json: + schema: { $ref: '#/components/schemas/OperationOutcome' } diff --git a/testing/readme.md b/testing/readme.md index 5a82c48e..bdd09b1c 100644 --- a/testing/readme.md +++ b/testing/readme.md @@ -13,7 +13,9 @@ makes them available through a FHIR API and a set of web pages. It runs at `/tes R4 and R5 TestReports are treated the same. A report is accepted if it is JSON, has `resourceType: TestReport`, and has `name`, `status`, `result`, `tester`, `issued` (a valid dateTime) and at least one `participant` with a `uri`. `score`, if present, must be a number. -Nothing else is checked. +Contained resources are not accepted. Nothing else is checked - but the TestReport schema in +the OpenAPI description (below) says what a valid report looks like, and reports should +conform to it. The server gives the report a new id (any id sent is ignored), sets `meta.lastUpdated` to the time it was received, and drops `meta.versionId`: reports can't be changed once received, so @@ -65,6 +67,46 @@ Unknown parameters are ignored, with a warning OperationOutcome in the Bundle, u request has `Prefer: handling=strict`, in which case they're a 400. Values that can't be understood (a bad date or number) are always a 400. +## OpenAPI description + +The FHIR API (create, read, search, metadata) is described by an OpenAPI 3.1 spec: + +| URL | | +|---|---| +| `/testing/openapi` | Browsable reference (HTML), with a "try it" form for each GET operation | +| `/testing/openapi.json` | The spec as JSON | +| `/testing/openapi.yaml` | The hand-written part, as YAML ([openapi.yaml](openapi.yaml)) | + +Every response carries a `Link: ; rel="service-desc"` header (RFC 8631), +and the HTML pages carry the matching `` element. Deleting reports and the +administration pages are deliberately not described. + +The spec is in two parts: + +* [openapi.yaml](openapi.yaml) - the paths, parameters and responses, written by hand +* [openapi-schemas.json](openapi-schemas.json) - the FHIR schemas (TestReport, Bundle, + OperationOutcome, the datatypes they use, and `TestReportSearchBundle`), generated from the R5 + StructureDefinitions by `library/fhir-openapi-schema.js`, as configured in + [openapi-schemas.config.js](openapi-schemas.config.js). The loader merges them into the spec. + +The generated schemas are closed (no properties FHIR doesn't define), include the `_x` +siblings of primitive elements (so extensions on primitives, like `_name`, are allowed), and +use the codes of required bindings as enums. Recursion is cut at two boundaries: an extension's +value isn't described, and a resource inside another (`Bundle.entry.resource`) is "any resource" +unless the config narrows it. `contained` is left out, so it isn't allowed. The config's overlay +adds what this server requires beyond the base resource, and lets `testScript` be an R4 +Reference. + +After changing the config, regenerate (this needs `hl7.fhir.r5.core#5.0.0` in the +terminology cache): + + node utilities/generate-openapi-schemas.js testing + +`tests/testing/openapi.test.js` fails if the generated file is out of date, if a route or search +parameter isn't described (or excluded), or if the TestReport schema doesn't require everything +`validateReport()` requires. It also validates what the server actually returns (a stored +report, a search Bundle, an OperationOutcome) against the schemas, using ajv. + ## Web pages * `/testing` - the list of reports, with filters (these map onto the search parameters above), diff --git a/testing/search.js b/testing/search.js index a36970df..7668e633 100644 --- a/testing/search.js +++ b/testing/search.js @@ -374,5 +374,5 @@ function capabilitySearchParams() { module.exports = { parseSearch, dateRange, splitValues, capabilitySearchParams, - PARAMS, DEFAULT_COUNT, MAX_COUNT + PARAMS, CONTROL, DEFAULT_COUNT, MAX_COUNT }; diff --git a/testing/testing-template.html b/testing/testing-template.html index 2afc3a7b..e9e572ce 100644 --- a/testing/testing-template.html +++ b/testing/testing-template.html @@ -9,6 +9,7 @@ + @@ -59,6 +60,7 @@ Server Home  |  Test Reports  |  Summary  |  + API diff --git a/testing/testing.js b/testing/testing.js index 1c818dd9..d20dbc75 100644 --- a/testing/testing.js +++ b/testing/testing.js @@ -46,6 +46,7 @@ const { requireSameOrigin } = require('../library/same-origin'); const packageJson = require('../package.json'); const { TestReportStore } = require('./store'); const { parseSearch, dateRange, capabilitySearchParams } = require('./search'); +const testingOpenApi = require('./openapi'); const { renderList, renderSummary, renderReport, renderLogin, renderLinks, renderUsers, listQueryToSearch } = require('./render'); @@ -94,6 +95,11 @@ function validateReport(r) { if (r.meta !== undefined && (r.meta === null || typeof r.meta !== 'object' || Array.isArray(r.meta))) { problems.push('TestReport.meta must be an object'); } + // contained resources aren't accepted: nothing in a report needs them, and allowing them + // would mean describing (and rendering) any resource at all + if (r.contained !== undefined) { + problems.push('TestReport.contained is not allowed: reports may not contain resources'); + } return problems; } @@ -223,6 +229,24 @@ class TestingModule { })); } + // RFC 8631: where to find the machine-readable description of this API + r.use((req, res, next) => { + res.setHeader('Link', `<${req.baseUrl}/openapi.json>; rel="service-desc", <${req.baseUrl}/openapi>; rel="service-doc"`); + next(); + }); + + // the OpenAPI description: /openapi.json, /openapi.yaml, and /openapi (an HTML reference + // for browsers, the JSON otherwise) + r.get('/openapi.json', (req, res) => this.handle(req, res, 'openapi', () => res.json(testingOpenApi.getSpec()))); + r.get('/openapi.yaml', (req, res) => this.handle(req, res, 'openapi', () => + res.set('Content-Type', 'application/yaml').send(testingOpenApi.getYaml()))); + r.get('/openapi', (req, res) => this.handle(req, res, 'openapi', () => { + if (!wantsHtml(req)) { + return res.json(testingOpenApi.getSpec()); + } + return this.sendHtml(res, 'Test Report API', testingOpenApi.renderHtml(), Date.now()); + })); + r.get('/', ...this.web, (req, res) => this.handle(req, res, 'list', () => this.htmlList(req, res))); r.get('/summary', ...this.web, (req, res) => this.handle(req, res, 'summary', () => this.htmlSummary(req, res))); r.get('/metadata', (req, res) => this.handle(req, res, 'metadata', () => this.metadata(req, res))); From 78788cb493004084c0207041298184f95df6e6e1 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 5 Oct 2026 18:05:08 +1300 Subject: [PATCH 05/16] Fix bug doing RxNorm searches --- tests/cs/cs-rxnorm.test.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/cs/cs-rxnorm.test.js b/tests/cs/cs-rxnorm.test.js index 23ec8de2..e106496c 100644 --- a/tests/cs/cs-rxnorm.test.js +++ b/tests/cs/cs-rxnorm.test.js @@ -625,6 +625,26 @@ describe('RxNorm Provider', () => { expect(count).toBeGreaterThan(0); console.log(`✓ Iterated ${count} codes via iterator`); }); + + testOrSkip('root iterator reports its total', async () => { + // the expander reads iter.total to refuse an over-limit whole-code-system expansion + const iterator = await provider.iterator(null); + expect(iterator.total).toBe(await provider.totalCount()); + }); + + testOrSkip('iterating a concept\'s children yields nothing, without error', async () => { + // the expander asks every concept for its children (includeCodeAndDescendants). This used + // to run an empty SQL statement, which node-sqlite3 rejects with + // "SQLITE_MISUSE: not an error" - so any expansion of all of RxNorm failed + const root = await provider.iterator(null); + const concept = await provider.nextContext(root); + expect(concept).toBeTruthy(); + + const children = await provider.iterator(concept); + expect(children).toBeDefined(); + expect(children.total).toBe(0); + await expect(provider.nextContext(children)).resolves.toBeNull(); + }); }); describe('Error Handling', () => { From e64753394751d8736ff79a24bcb838c7f95150d8 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 5 Oct 2026 18:06:39 +1300 Subject: [PATCH 06/16] fix tx server for handlng contained resources correctly --- translations/Messages.properties | 1 + tx/cm/cm-database.js | 11 ++++++- tx/cm/cm-package.js | 9 +++++- tx/cs/cs-rxnorm.js | 29 ++++++++++++++---- tx/library.js | 19 ++++++++++-- tx/library/canonical-resource.js | 51 +++++++++++++++++++++++++++++++- tx/library/codesystem.js | 1 + tx/library/conceptmap.js | 1 + tx/library/valueset.js | 16 ++++++++++ tx/operation-context.js | 15 ++++++++++ tx/provider.js | 11 ++++++- tx/vs/vs-database.js | 19 ++++++++---- tx/vs/vs-package.js | 10 +++++++ tx/workers/expand.js | 51 ++++++++++++++++++++++---------- tx/workers/validate.js | 23 ++++++++++++-- tx/workers/worker.js | 37 ++++++++++++++++++----- 16 files changed, 260 insertions(+), 44 deletions(-) diff --git a/translations/Messages.properties b/translations/Messages.properties index bd031b59..9ada839b 100644 --- a/translations/Messages.properties +++ b/translations/Messages.properties @@ -1288,6 +1288,7 @@ VALUESET_INCLUDE_CSVER_NOT_FOUND = No matching contained code system found for s VALUESET_INCLUDE_CSVER_SUPPLEMENT = The value set references CodeSystem ''{0}'' version ''{2}'' which is a supplement. It must reference the underlying CodeSystem ''{1}'' and use the http://hl7.org/fhir/StructureDefinition/valueset-supplement extension for the supplement VALUESET_INCLUDE_INVALID_CONCEPT_CODE = The code ''{1}'' is not valid in the system {0} ({2}) VALUESET_INCLUDE_INVALID_CONCEPT_CODE_VER = The code ''{2}'' is not valid in the system {0} version {1} ({2}) +VALUESET_NO_COMPOSE = The value set {0} has no compose and no expansion, so it cannot be expanded VALUESET_INCLUDE_SYSTEM_ABSOLUTE = URI values in ValueSet.compose.include.system must be absolute VALUESET_INCLUDE_SYSTEM_ABSOLUTE_FRAG = URI values in ValueSet.compose.include.system must be absolute. To reference a contained code system, use the full CodeSystem URL and reference it using the http://hl7.org/fhir/StructureDefinition/valueset-system extension VALUESET_INCLUDE_WRONG_CS_OID = It is not valid to refer to a CodeSystem by an identifier like this ''{0}'' - use ''{1}'' diff --git a/tx/cm/cm-database.js b/tx/cm/cm-database.js index d825625f..90efa41f 100644 --- a/tx/cm/cm-database.js +++ b/tx/cm/cm-database.js @@ -349,7 +349,16 @@ class ConceptMapDatabase { this.cmCount = rows.length; for (const row of rows) { - const conceptMap = new ConceptMap(JSON.parse(row.content)); + let conceptMap; + try { + conceptMap = new ConceptMap(JSON.parse(row.content)); + } catch (e) { + // one unacceptable concept map must not take the rest with it + require('../../library/logger').getInstance().child({ module: 'tx' }) + .warn(`ConceptMap database: skipping ConceptMap ${row.url || row.id}: ${e.message}`); + this.cmCount--; + continue; + } // Store by URL and id alone conceptMapMap.set(row.url, conceptMap); diff --git a/tx/cm/cm-package.js b/tx/cm/cm-package.js index 7a8a3c19..b987fe9d 100644 --- a/tx/cm/cm-package.js +++ b/tx/cm/cm-package.js @@ -63,7 +63,14 @@ class PackageConceptMapProvider extends AbstractConceptMapProvider { for (const entry of conceptMapEntries) { const conceptMap = await this.packageLoader.loadFile(entry); if (conceptMap.url) { - conceptMaps.push(new ConceptMap(conceptMap, this.packageLoader.fhirVersion())); // get in converted to R5 format + try { + conceptMaps.push(new ConceptMap(conceptMap, this.packageLoader.fhirVersion())); // get in converted to R5 format + } catch (e) { + // one unacceptable concept map (e.g. one with contained resources, which the + // server doesn't support) must not stop the rest of the package loading + require('../../library/logger').getInstance().child({ module: 'tx' }) + .warn(`Package ${this.packageLoader.pid ? this.packageLoader.pid() : ''}: skipping ConceptMap ${conceptMap.url}: ${e.message}`); + } } } diff --git a/tx/cs/cs-rxnorm.js b/tx/cs/cs-rxnorm.js index 5f59199b..62f9bdcf 100644 --- a/tx/cs/cs-rxnorm.js +++ b/tx/cs/cs-rxnorm.js @@ -36,12 +36,27 @@ class RxNormPrep { // Iterator context class RxNormIteratorContext { - constructor(query, params = {}) { + /** + * @param {string|null} query - the SQL that produces the rows, or null for an iterator with no rows + * @param {object} params - the query parameters + * @param {number} [total] - the number of rows the query will return, if known. The expander + * reads it to refuse an over-limit whole-code-system expansion before it is materialised + */ + constructor(query, params = {}, total = undefined) { this.query = query; this.params = params; this.cursor = 0; - this.results = null; - this.executed = false; + if (query) { + this.results = null; + this.executed = false; + this.total = total; + } else { + // nothing to run: an empty statement isn't a no-op in node-sqlite3, it fails with + // "SQLITE_MISUSE: not an error" + this.results = []; + this.executed = true; + this.total = 0; + } } more() { @@ -288,10 +303,12 @@ class RxNormServices extends CodeSystemProvider { if (!context) { // Iterate all codes const query = `SELECT ${this.getCodeField()}, STR FROM rxnconso WHERE SAB = ? AND TTY <> 'SY' ORDER BY ${this.getCodeField()}`; - return new RxNormIteratorContext(query, { sab: this.getSAB() }); + return new RxNormIteratorContext(query, { sab: this.getSAB() }, this.totalCodeCount); } else { - // No hierarchical iteration for specific contexts in this implementation - return new RxNormIteratorContext('', {}); + // No hierarchical iteration for specific contexts in this implementation. The expander + // asks every concept for its children (includeCodeAndDescendants), so this has to be an + // iterator with no rows - not one with an empty query, which sqlite rejects + return new RxNormIteratorContext(null, {}); } } diff --git a/tx/library.js b/tx/library.js index 3c37acc4..0314ad3a 100644 --- a/tx/library.js +++ b/tx/library.js @@ -664,7 +664,15 @@ class Library { const resources = await contentLoader.getResourcesByType("CodeSystem"); let csc = 0; for (const resource of resources) { - const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion())); + let cs; + try { + cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion())); + } catch (e) { + // one unacceptable resource (e.g. contained resources the server doesn't support) + // must not stop the rest of the package loading + this.log.warn(`Package ${contentLoader.pid()}: skipping CodeSystem ${resource.id || resource.filename || ''}: ${e.message}`); + continue; + } if (this.#isIgnored(cs.url, cs.version)) { this.log.info(`Ignoring CodeSystem ${cs.url}${cs.version ? '#' + cs.version : ''} (excluded by config)`); continue; @@ -775,7 +783,14 @@ class Library { const csEntries = await contentLoader.getResourcesByType("CodeSystem"); let csc = 0; for (const entry of csEntries) { - const cs = new CodeSystem(await contentLoader.loadFile(entry, contentLoader.fhirVersion())); + let cs; + try { + cs = new CodeSystem(await contentLoader.loadFile(entry, contentLoader.fhirVersion())); + } catch (e) { + // one unacceptable resource must not stop the rest of the package loading + this.log.warn(`Package ${contentLoader.pid()}: skipping CodeSystem ${entry.id || entry.filename || ''}: ${e.message}`); + continue; + } if (this.#isIgnored(cs.url, cs.version)) { this.log.info(`Ignoring CodeSystem ${cs.url}${cs.version ? '#' + cs.version : ''} (excluded by config)`); continue; diff --git a/tx/library/canonical-resource.js b/tx/library/canonical-resource.js index e7c30632..db9a80b6 100644 --- a/tx/library/canonical-resource.js +++ b/tx/library/canonical-resource.js @@ -1,4 +1,45 @@ const {VersionUtilities, VersionPrecision} = require("../../library/version-utilities"); +const {Issue} = require("./operation-outcome"); + +/** + * Contained resources. The terminology server supports exactly one use of them: a ValueSet + * that contains ValueSets, which its compose refers to as #id. Any other contained resource + * - a contained CodeSystem or ConceptMap, anything contained in a CodeSystem or ConceptMap, + * or a contained ValueSet that itself contains something - is rejected, whatever path the + * resource arrives by (request, tx-resource, cache, package). Supporting them would mean + * resolving references to them everywhere, and the terminology ecosystem only requires + * servers to support contained value sets in value sets. + * + * @param {Object} jsonObj - the resource (R5 form) + * @throws {Issue} if the resource contains anything not supported + */ +function checkContained(jsonObj) { + const contained = jsonObj ? jsonObj.contained : undefined; + if (contained === undefined || contained === null) { + return; + } + const type = jsonObj.resourceType; + const which = `${type}${jsonObj.id ? '/' + jsonObj.id : ''}${jsonObj.url ? ' (' + jsonObj.url + ')' : ''}`; + if (!Array.isArray(contained)) { + throw new Issue('error', 'structure', `${type}.contained`, 'CONTAINED_RESOURCE_NOT_SUPPORTED', + `${which}: contained must be an array`, 'invalid-data', 400); + } + contained.forEach((c, i) => { + const ct = c && typeof c === 'object' ? c.resourceType : undefined; + let problem = null; + if (type !== 'ValueSet') { + problem = `${which} contains a ${ct || 'resource'}: this server only supports ValueSets contained in a ValueSet`; + } else if (ct !== 'ValueSet') { + problem = `${which} contains a ${ct || 'resource with no resourceType'}: this server only supports ValueSets contained in a ValueSet`; + } else if (Array.isArray(c.contained) && c.contained.length > 0) { + problem = `${which}: the contained ValueSet${c.id ? ' #' + c.id : ''} itself contains resources, which is not allowed`; + } + if (problem) { + throw new Issue('error', 'not-supported', `${type}.contained[${i}]`, 'CONTAINED_RESOURCE_NOT_SUPPORTED', + problem, 'not-supported', 400); + } + }); +} /** * Base class for metadata resources to provide common interface @@ -31,6 +72,14 @@ class CanonicalResource { this.fhirVersion = fhirVersion; } + /** + * Rejects contained resources this server doesn't support - see checkContained. + * Subclasses call this once the resource is in R5 form. + */ + checkContained() { + checkContained(this.jsonObj); + } + get resourceType() { return this.jsonObj.resourceType; } @@ -163,4 +212,4 @@ class CanonicalResource { } } -module.exports = { CanonicalResource }; +module.exports = { CanonicalResource, checkContained }; diff --git a/tx/library/codesystem.js b/tx/library/codesystem.js index be136fee..d8bc4d8b 100644 --- a/tx/library/codesystem.js +++ b/tx/library/codesystem.js @@ -55,6 +55,7 @@ class CodeSystem extends CanonicalResource { if (e.issueCode) { wrapped.issueCode = e.issueCode; } throw wrapped; } + this.checkContained(); if (!noMaps) { this.buildMaps(); } diff --git a/tx/library/conceptmap.js b/tx/library/conceptmap.js index 5a172f00..5d76b110 100644 --- a/tx/library/conceptmap.js +++ b/tx/library/conceptmap.js @@ -19,6 +19,7 @@ class ConceptMap extends CanonicalResource { // Convert to R5 format internally (modifies input for performance) this.jsonObj = conceptMapToR5(jsonObj, fhirVersion); this.validate(); + this.checkContained(); this.id = this.jsonObj.id; // Precalculated at construction so callers (e.g. the resource cache) have a // cheap O(1) sense of how large this resource is. diff --git a/tx/library/valueset.js b/tx/library/valueset.js index 602ffcac..91b8a931 100644 --- a/tx/library/valueset.js +++ b/tx/library/valueset.js @@ -25,12 +25,28 @@ class ValueSet extends CanonicalResource { // Convert to R5 format internally (modifies input for performance) this.jsonObj = valueSetToR5(jsonObj, fhirVersion); this.validate(); + this.checkContained(); this.buildMaps(); // Precalculated at construction so callers (e.g. the resource cache) have a // cheap O(1) sense of how large this resource is. this._conceptCount = ValueSet._countConcepts(this.jsonObj, this._containsCount); } + /** + * What circular reference detection knows this value set by: its versioned url, or, for + * a contained value set (see TerminologyWorker.findValueSet), its container's key plus + * #id - a contained value set's own url, if it has one, says nothing about which + * container it came from. null for a value set with no url that isn't contained. + * @returns {string|null} + */ + get contextKey() { + if (this.isContained && this.container) { + const base = this.container.contextKey || this.container.vurl || '(unidentified)'; + return `${base}#${this.jsonObj.id || ''}`; + } + return this.vurl || null; + } + /** * Number of concepts this ValueSet carries: the enumerated concepts in its * compose (include + exclude `concept` lists) plus any concepts in an inline diff --git a/tx/operation-context.js b/tx/operation-context.js index 6297e03f..43a48553 100644 --- a/tx/operation-context.js +++ b/tx/operation-context.js @@ -1084,6 +1084,21 @@ class OperationContext { this.contexts.push(vurl); } + /** + * Stop tracking a context, once processing of that value set is finished. The tracked + * contexts are the chain of value sets currently being processed, not every value set + * seen in the operation: a value set used twice (imported by two others, or twice by + * one) is not a circularity - only one that is reached again while it's being + * processed is. + * @param {string} vurl - the url passed to seeContext + */ + unseeContext(vurl) { + const i = this.contexts.lastIndexOf(vurl); + if (i >= 0) { + this.contexts.splice(i, 1); + } + } + /** * Clear all tracked contexts */ diff --git a/tx/provider.js b/tx/provider.js index 0c79676a..42d6cc16 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -172,7 +172,16 @@ class Provider { const resources = await contentLoader.getResourcesByType("CodeSystem"); for (const resource of resources) { - const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion())); + let cs; + try { + cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion())); + } catch (e) { + // one unacceptable resource (e.g. contained resources the server doesn't support) + // must not stop the rest of the package loading + require('../library/logger').getInstance().child({ module: 'tx' }) + .warn(`Package ${contentLoader.pid()}: skipping CodeSystem ${resource.id || resource.filename || ''}: ${e.message}`); + continue; + } cs.sourcePackage = contentLoader.pid(); this.addCodeSystem(cs); } diff --git a/tx/vs/vs-database.js b/tx/vs/vs-database.js index 10191d1a..88a32b1f 100644 --- a/tx/vs/vs-database.js +++ b/tx/vs/vs-database.js @@ -743,7 +743,17 @@ class ValueSetDatabase { const valueSetMap = new Map(); for (const row of rows) { - const valueSet = new ValueSet(JSON.parse(row.content)); + let valueSet; + try { + valueSet = new ValueSet(JSON.parse(row.content)); + } catch (e) { + // one unacceptable value set (e.g. one containing a CodeSystem, which the + // server doesn't support) must not take the whole package's value sets with it + require('../../library/logger').getInstance().child({ module: 'tx' }) + .warn(`${source || 'ValueSet database'}: skipping ValueSet ${row.url || row.id}${row.version ? '|' + row.version : ''}: ${e.message}`); + this.vsCount--; + continue; + } valueSet.sourcePackage = source; // Attach the stored content hash so callers can detect changes // without recomputing over the full JSON. @@ -839,10 +849,9 @@ class ValueSetDatabase { }); } else { // Fall back to parsing JSON - results = rows.map(row => { - const vs = map.get(row.id); - return vs; - }); + // a value set the map doesn't have was rejected when the map was loaded + // (see loadAllValueSets), so it isn't a result + results = rows.map(row => map.get(row.id)).filter(vs => vs); } resolve(results); diff --git a/tx/vs/vs-package.js b/tx/vs/vs-package.js index 9bb7cd0e..13ff6b87 100644 --- a/tx/vs/vs-package.js +++ b/tx/vs/vs-package.js @@ -3,6 +3,7 @@ const fs = require('fs').promises; const { AbstractValueSetProvider } = require('./vs-api'); const { PackageContentLoader } = require('../../library/package-manager'); const { ValueSetDatabase } = require('./vs-database'); +const { checkContained } = require('../library/canonical-resource'); const { VersionUtilities } = require('../../library/version-utilities'); const {validateParameter} = require("../../library/utilities"); @@ -126,6 +127,15 @@ class PackageValueSetProvider extends AbstractValueSetProvider { for (const entry of valueSetEntries) { const valueSet = await this.packageLoader.loadFile(entry); if (valueSet.url) { + try { + // don't store a value set the server can't use (e.g. one that contains a + // CodeSystem): it would be listed by search, and fail when read + checkContained(valueSet); + } catch (e) { + require('../../library/logger').getInstance().child({ module: 'tx' }) + .warn(`Package ${this.packageLoader.pid ? this.packageLoader.pid() : ''}: skipping ValueSet ${valueSet.url}: ${e.message}`); + continue; + } valueSets.push(valueSet); } } diff --git a/tx/workers/expand.js b/tx/workers/expand.js index d9e09f83..f39e1aa1 100644 --- a/tx/workers/expand.js +++ b/tx/workers/expand.js @@ -620,19 +620,23 @@ class ValueSetExpander { return count; } + /** + * Exclude the codes of an (expanded) value set. Excludes are processed before includes, + * so the codes are recorded as excluded - as excludeCode does - and the includes then + * skip them. (This used to remove them from what had been included so far, which at + * that point is nothing, so excluding a value set excluded nothing.) + */ excludeValueSet(vs, expansion, imports, offset) { - for (const c of vs.expansion.contains) { - this.worker.deadCheck('excludeValueSet'); - const s = this.keyC(c); - if (this.passesImports(imports, c.system, c.code, offset) && this.map.has(s)) { - const idx = this.fullList.indexOf(this.map.get(s)); - if (idx >= 0) { - this.fullList.splice(idx, 1); + const walk = (list) => { + for (const c of list || []) { + this.worker.deadCheck('excludeValueSet'); + if (c.code && this.passesImports(imports, c.system, c.code, offset)) { + this.excluded.add((this.doingVersion && !this.params.versionsMatch ? c.system + '|' + c.version : c.system) + '#' + c.code); } - this.map.delete(s); - this.decTotal(); + walk(c.contains); } - } + }; + walk(vs.expansion.contains); } async checkSource(cset, exp, filter, srcURL, ts, vsInfo , source) { @@ -1012,8 +1016,10 @@ class ValueSetExpander { let vs = await this.worker.findValueSet(s, '', vsSrc); const ivs = new ImportedValueSet(await this.expandValueSet(s, '', vs, filter, notClosed)); this.checkResourceCanonicalStatus(expansion, ivs.valueSet, this.valueSet); - if (!vs.isContained && ivs.valueSet.vurl) { - this.addParamUri(expansion, 'used-valueset', ivs.valueSet.vurl); + // ivs.valueSet is the expansion (plain JSON), so it has no vurl - as for the + // includes, build it + if (!vs.isContained && this.worker.makeVurl(ivs.valueSet)) { + this.addParamUri(expansion, 'used-valueset', this.worker.makeVurl(ivs.valueSet)); } valueSets.push(ivs); } @@ -1314,13 +1320,22 @@ class ValueSetExpander { } async expand(source, filter, noCacheThisOne) { - this.noCacheThisOne = noCacheThisOne; - this.totalStatus = 'uninitialised'; - this.total = 0; - Extensions.checkNoImplicitRules(source,'ValueSetExpander.Expand', 'ValueSet', source.vurl); Extensions.checkNoModifiers(source,'ValueSetExpander.Expand', 'ValueSet', source.vurl); + // circular reference detection: this value set is in the chain being processed until + // its expansion is done this.worker.seeValueSet(source, this.params); + try { + return await this.expandSeen(source, filter, noCacheThisOne); + } finally { + this.worker.unseeValueSet(source); + } + } + + async expandSeen(source, filter, noCacheThisOne) { + this.noCacheThisOne = noCacheThisOne; + this.totalStatus = 'uninitialised'; + this.total = 0; this.valueSet = source; const result = structuredClone(source.jsonObj); @@ -1348,6 +1363,10 @@ class ValueSetExpander { if (result.expansion) { return result; // just return the expansion } + if (!source.jsonObj.compose) { + throw new Issue('error', 'invalid', null, 'VALUESET_NO_COMPOSE', + this.worker.i18n.translate('VALUESET_NO_COMPOSE', this.params.httpLanguages, [source.contextKey || source.vurlOrMsg]), 'vs-invalid', 422); + } if (this.params.generateNarrative && !this.noDetails) { div_ = div(); diff --git a/tx/workers/validate.js b/tx/workers/validate.js index d99cb448..6142a9b7 100644 --- a/tx/workers/validate.js +++ b/tx/workers/validate.js @@ -285,7 +285,10 @@ class ValueSetChecker { } seeValueSet() { - this.worker.opContext.seeContext(this.valueSet.vurl); + const key = this.valueSet.contextKey !== undefined ? this.valueSet.contextKey : this.valueSet.vurl; + if (key) { + this.worker.opContext.seeContext(key); + } if (this.valueSet.jsonObj.compose && this.valueSet.jsonObj.compose.extension) { for (let ext of this.valueSet.jsonObj.compose.extension) { if (ext.url === 'http://hl7.org/fhir/StructureDefinition/valueset-expansion-parameter' || ext.url === 'http://hl7.org/fhir/tools/StructureDefinition/valueset-expansion-parameter') { @@ -305,8 +308,22 @@ class ValueSetChecker { async prepare() { if (this.valueSet === null) { throw new Issue('error', 'not-found', null, null, 'Error Error: vs = nil', null, 422); - } else { - this.seeValueSet(); + } + // circular reference detection: this value set is in the chain being processed until + // it's prepared (which prepares everything it imports) + this.seeValueSet(); + try { + await this.prepareSeen(); + } finally { + const key = this.valueSet.contextKey !== undefined ? this.valueSet.contextKey : this.valueSet.vurl; + if (key) { + this.worker.opContext.unseeContext(key); + } + } + } + + async prepareSeen() { + { this.worker.opContext.addNote(this.valueSet, 'Analysing ' + this.valueSet.vurl + ' for validation purposes', this.indentCount); if (this.indentCount === 0) { this.worker.opContext.addNote(this.valueSet, 'Parameters: ' + this.params.summary(), this.indentCount); diff --git a/tx/workers/worker.js b/tx/workers/worker.js index 52925293..216a137d 100644 --- a/tx/workers/worker.js +++ b/tx/workers/worker.js @@ -379,14 +379,22 @@ class TerminologyWorker { return null; } if (url.startsWith("#")) { + // A local reference: per FHIR, #id is resolved against the contained resources of + // the resource being processed - which, when that is itself a contained value set, + // means its container's (contained resources are all siblings in one list) if (source) { - if (source.jsonObj) { - source = source.jsonObj; - } - for (const contained of source.contained || []) { - if (contained.id === url.substring(1)) { + const container = source.container || source; + const json = container.jsonObj || container; + for (const contained of json.contained || []) { + if (contained && contained.id === url.substring(1)) { + if (contained.resourceType !== 'ValueSet') { + // the resource wrappers already reject this; this is the second line of defence + throw new Issue('error', 'not-supported', null, 'CONTAINED_RESOURCE_NOT_SUPPORTED', + `The reference '${url}' is to a contained ${contained.resourceType}, not a ValueSet`, 'not-supported', 400); + } const ret = this.wrapRawResource(contained); ret.isContained = true; + ret.container = container.jsonObj ? container : this.wrapRawResource(container); return ret; } } @@ -477,11 +485,24 @@ class TerminologyWorker { * Note: this is the caller's parameters, not this.params - the worker's own * params is null when expanding an imported ValueSet */ + /** The circular reference key seeValueSet tracks for a value set */ + valueSetContextKey(vs) { + return vs.contextKey !== undefined ? vs.contextKey : (vs.url ? (vs.url + (vs.version ? '|' + vs.version : '')) : null); + } + + /** Finished processing a value set: see OperationContext.unseeContext */ + unseeValueSet(vs) { + const key = this.valueSetContextKey(vs); + if (key) { + this.opContext.unseeContext(key); + } + } + seeValueSet(vs, params) { // Build canonical URL from url and version - const vurl = vs.url ? (vs.url + (vs.version ? '|' + vs.version : '')) : null; - if (vurl) { - this.opContext.seeContext(vurl); + const key = this.valueSetContextKey(vs); + if (key) { + this.opContext.seeContext(key); } // Check for expansion parameter extensions on compose if (vs.jsonObj.compose && vs.jsonObj.compose.extension) { From 21a2714f52fa4dba7030ff26c3a35460456a249d Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Mon, 5 Oct 2026 18:07:01 +1300 Subject: [PATCH 07/16] fix xig displayed version --- utilities/extract-schema-json.js | 149 ++++++++++++++++++++++++++ utilities/generate-openapi-schemas.js | 49 +++++++++ xig/xig.js | 6 +- 3 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 utilities/extract-schema-json.js create mode 100644 utilities/generate-openapi-schemas.js diff --git a/utilities/extract-schema-json.js b/utilities/extract-schema-json.js new file mode 100644 index 00000000..c57b45d2 --- /dev/null +++ b/utilities/extract-schema-json.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// +// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au) +// +// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause +// + +// The published FHIR specification has a page per resource for its JSON schema +// (e.g. testreport.schema.json.html), but not the schema itself as a file, so links to +// testreport.schema.json fail. This walks a tree of published specifications and, for each +// X.schema.json.html, takes the content of its
 element and writes it as X.schema.json
+// beside it.
+//
+//   node utilities/extract-schema-json.js  [ ...] [--dry-run] [--force] [--verbose]
+//
+//   --dry-run  report what would be written, but write nothing
+//   --force    overwrite an existing X.schema.json whose content differs (by default it's
+//              left alone and reported)
+//   --verbose  list every file written or skipped
+//   --no-recurse  only look in the named directories themselves, not below them
+//
+// It can be stopped and run again: files already written are reported as up to date.
+//
+// A page is skipped (and reported) if it doesn't have exactly one 
, or if the content
+// isn't valid JSON. Symbolic links are not followed. Exit status is 1 if anything was
+// skipped for a problem, so it can be run in a script.
+
+const fs = require('fs');
+const path = require('path');
+
+const SUFFIX = '.schema.json.html';
+
+const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ' };
+
+function decodeEntities(text) {
+  return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (m, e) => {
+    if (e[0] === '#') {
+      const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.substring(2), 16) : parseInt(e.substring(1), 10);
+      return Number.isFinite(code) ? String.fromCodePoint(code) : m;
+    }
+    const v = NAMED_ENTITIES[e.toLowerCase()];
+    return v === undefined ? m : v;
+  });
+}
+
+/**
+ * The schema in a page: the text of its one 
 element, with any markup removed and
+ * entities decoded.
+ *
+ * @param {string} html
+ * @returns {{json?: string, problem?: string}}
+ */
+function extractSchema(html) {
+  const pres = [...html.matchAll(/]*>([\s\S]*?)<\/pre>/gi)];
+  if (pres.length !== 1) {
+    return { problem: pres.length === 0 ? 'no 
 element' : `${pres.length} 
 elements` };
+  }
+  const text = decodeEntities(pres[0][1].replace(/<[^>]*>/g, '')).trim() + '\n';
+  try {
+    JSON.parse(text);
+  } catch (e) {
+    return { problem: 'the 
 content is not valid JSON: ' + e.message };
+  }
+  return { json: text };
+}
+
+async function* walk(dir, recurse = true) {
+  let handle;
+  try {
+    handle = await fs.promises.opendir(dir);
+  } catch (e) {
+    console.error(`Cannot read ${dir}: ${e.message}`);
+    return;
+  }
+  for await (const entry of handle) {
+    const p = path.join(dir, entry.name);
+    if (entry.isDirectory()) {
+      if (recurse) {
+        yield* walk(p);
+      }
+    } else if (entry.isFile() && entry.name.endsWith(SUFFIX)) {
+      yield p;
+    }
+  }
+}
+
+async function run(roots, options = {}) {
+  const counts = { pages: 0, written: 0, unchanged: 0, different: 0, problems: 0 };
+  const log = (msg) => options.verbose && console.log(msg);
+  for (const root of roots) {
+    for await (const page of walk(root, !options.noRecurse)) {
+      counts.pages++;
+      const target = page.slice(0, -'.html'.length);
+      const { json, problem } = extractSchema(await fs.promises.readFile(page, 'utf8'));
+      if (problem) {
+        counts.problems++;
+        console.warn(`${page}: ${problem}`);
+        continue;
+      }
+      let existing = null;
+      try {
+        existing = await fs.promises.readFile(target, 'utf8');
+      } catch (e) {
+        // not there yet
+      }
+      if (existing === json) {
+        counts.unchanged++;
+        continue;
+      }
+      if (existing !== null && !options.force) {
+        counts.different++;
+        console.warn(`${target}: exists with different content - left alone (--force to overwrite)`);
+        continue;
+      }
+      if (!options.dryRun) {
+        await fs.promises.writeFile(target, json);
+      }
+      counts.written++;
+      log(`${options.dryRun ? 'would write' : 'wrote'} ${target}`);
+    }
+  }
+  return counts;
+}
+
+async function main(args) {
+  const options = {
+    dryRun: args.includes('--dry-run'),
+    force: args.includes('--force'),
+    verbose: args.includes('--verbose'),
+    noRecurse: args.includes('--no-recurse')
+  };
+  const roots = args.filter(a => !a.startsWith('--'));
+  if (roots.length === 0) {
+    console.error('usage: extract-schema-json.js  [ ...] [--dry-run] [--force] [--verbose] [--no-recurse]');
+    process.exit(2);
+  }
+  const start = Date.now();
+  const c = await run(roots, options);
+  console.log(`${c.pages} schema pages: ${c.written} ${options.dryRun ? 'to write' : 'written'}, ` +
+    `${c.unchanged} already up to date, ${c.different} existing and different (left alone), ` +
+    `${c.problems} with problems (${((Date.now() - start) / 1000).toFixed(1)}s)`);
+  process.exit(c.problems > 0 ? 1 : 0);
+}
+
+if (require.main === module) {
+  main(process.argv.slice(2));
+}
+
+module.exports = { extractSchema, decodeEntities, run };
diff --git a/utilities/generate-openapi-schemas.js b/utilities/generate-openapi-schemas.js
new file mode 100644
index 00000000..c2cde6e8
--- /dev/null
+++ b/utilities/generate-openapi-schemas.js
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+//
+// Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
+//
+// Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
+//
+
+// Generates a module's FHIR component schemas for its OpenAPI description, from the
+// StructureDefinitions in a FHIR package (see library/fhir-openapi-schema.js).
+//
+//   node utilities/generate-openapi-schemas.js  [-package ]
+//
+// reads /openapi-schemas.config.js and writes /openapi-schemas.json. The
+// package is found in the server's terminology cache unless -package names the unpacked
+// package directory.
+
+const fs = require('fs');
+const path = require('path');
+const { generateSchemas } = require('../library/fhir-openapi-schema');
+
+function packageDir(id) {
+  const folders = require('../library/folder-setup');
+  return folders.filePath('terminology-cache', id, 'package');
+}
+
+function main(args) {
+  const module = args[0];
+  if (!module) {
+    console.error('usage: generate-openapi-schemas.js  [-package ]');
+    process.exit(2);
+  }
+  const moduleDir = path.join(__dirname, '..', module);
+  const config = require(path.join(moduleDir, 'openapi-schemas.config.js'));
+  const i = args.indexOf('-package');
+  const dir = i >= 0 ? args[i + 1] : packageDir(config.package);
+  if (!fs.existsSync(dir)) {
+    console.error(`The package ${config.package} isn't at ${dir}. Load it into the terminology cache, or use -package `);
+    process.exit(1);
+  }
+  const out = path.join(moduleDir, 'openapi-schemas.json');
+  fs.writeFileSync(out, JSON.stringify(generateSchemas(config, dir), null, 2) + '\n');
+  console.log(`Wrote ${out}`);
+}
+
+if (require.main === module) {
+  main(process.argv.slice(2));
+}
+
+module.exports = { packageDir };
diff --git a/xig/xig.js b/xig/xig.js
index 27c4fe31..180be7ab 100644
--- a/xig/xig.js
+++ b/xig/xig.js
@@ -397,8 +397,7 @@ async function gatherPageStatistics() {
       downloadDate: downloadDate,
       totalResources: tableCounts.resources || 0,
       totalPackages: tableCounts.packages || 0,
-      processingTime: processingTime,
-      version: getMetadata('fhir-version') || '4.0.1'
+      processingTime: processingTime
     };
 
   } catch (error) {
@@ -411,8 +410,7 @@ async function gatherPageStatistics() {
       downloadDate: 'Error',
       totalResources: 0,
       totalPackages: 0,
-      processingTime: processingTime,
-      version: '4.0.1'
+      processingTime: processingTime
     };
   }
 }

From e74d3cdcbf880099e1e05c705abdaeefb701cb43 Mon Sep 17 00:00:00 2001
From: Grahame Grieve 
Date: Mon, 5 Oct 2026 18:07:18 +1300
Subject: [PATCH 08/16] tests related to openAPI support

---
 README.md                                  |   5 +-
 server.js                                  |   6 +-
 tests/packages/openapi.test.js             | 172 ++++++++++++
 tests/packages/search.test.js              | 287 +++++++++++++++++++++
 tests/registry/openapi.test.js             | 201 +++++++++++++++
 tests/registry/registry-exclusions.test.js |  15 +-
 tests/registry/registry-resolve.test.js    | 105 +++++++-
 tests/server/openapi-doc.test.js           |  92 +++++++
 tests/testing/openapi.test.js              | 255 ++++++++++++++++++
 tests/tx/contained.test.js                 | 183 +++++++++++++
 tests/utils/openapi-helpers.js             | 145 +++++++++++
 11 files changed, 1452 insertions(+), 14 deletions(-)
 create mode 100644 tests/packages/openapi.test.js
 create mode 100644 tests/packages/search.test.js
 create mode 100644 tests/registry/openapi.test.js
 create mode 100644 tests/server/openapi-doc.test.js
 create mode 100644 tests/testing/openapi.test.js
 create mode 100644 tests/tx/contained.test.js
 create mode 100644 tests/utils/openapi-helpers.js

diff --git a/README.md b/README.md
index 1f8cb887..fdf88ede 100644
--- a/README.md
+++ b/README.md
@@ -10,10 +10,11 @@ This server provides a set of server-side services that are useful for the FHIR
 
 ## Services useful the community as a whole
 
-* [TX Registry](registry/readme.md) - **Terminology System Registry** as [described by the terminology ecosystem specification](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig) (as running at http://tx.fhir.org/tx-reg)
-* [Package server](packages/readme.md) - **NPM-style FHIR package registry** with search, versioning, and downloads, consistent with the FHIR NPM Specification (as running at http://packages2.fhir.org/packages)
+* [TX Registry](registry/readme.md) - **Terminology System Registry** as [described by the terminology ecosystem specification](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig) (as running at http://tx.fhir.org/tx-reg). Its API is described by an OpenAPI 3.1 spec at `/tx-reg/openapi.json` (and `.yaml`), with a browsable reference at `/tx-reg/openapi`
+* [Package server](packages/readme.md) - **NPM-style FHIR package registry** with search, versioning, and downloads, consistent with the FHIR NPM Specification (as running at http://packages2.fhir.org/packages). Its API is described by an OpenAPI 3.1 spec at `/packages/openapi.json` (and `.yaml`), with a browsable reference at `/packages/openapi`
 * [XIG server](xig/readme.md) -  **Comprehensive FHIR IG analytics** with resource breakdowns by version, authority, and realm (as running at http://packages2.fhir.org/packages)
 * [Publisher](publisher/readme.md) - FHIR publishing services (as running at [healthintersections.com.au](http://www.healthintersections.com.au/publisher))
+* [Testing](testing/readme.md) - **TestReport repository**: receives FHIR TestReports from TxTester and other test tools, with a FHIR API and web pages (as running at https://testing.fhir.org/testing). Its API is described by an OpenAPI 3.1 spec at `/testing/openapi.json` (and `.yaml`), with a browsable reference at `/testing/openapi`
 * [VCL](vcl/readme.md) - **Parse VCL expressions** into FHIR ValueSet resources (as running at http://fhir.org/vcl)
 * (Coming) Token services
 
diff --git a/server.js b/server.js
index 98b9c511..8be45b63 100644
--- a/server.js
+++ b/server.js
@@ -314,7 +314,7 @@ async function buildRootPageContent() {
   // Check which modules are enabled and add them to the list
   if (config.modules.packages.enabled) {
     content += '
  • '; - content += 'Package Server: Browse and download FHIR Implementation Guide packages'; + content += 'Package Server: Browse and download FHIR Implementation Guide packages (API)'; content += '
  • '; } @@ -339,7 +339,7 @@ async function buildRootPageContent() { if (config.modules.registry && config.modules.registry.enabled) { content += '
  • '; content += 'Terminology Server Registry: '; - content += 'Discover and query FHIR terminology servers for code system and value set support'; + content += 'Discover and query FHIR terminology servers for code system and value set support (API)'; content += '
  • '; } @@ -374,7 +374,7 @@ async function buildRootPageContent() { if (config.modules?.testing?.enabled) { content += '
  • '; content += 'Test Reports: '; - content += 'TestReports submitted by TxTester and other test tools'; + content += 'TestReports submitted by TxTester and other test tools (API)'; content += '
  • '; } diff --git a/tests/packages/openapi.test.js b/tests/packages/openapi.test.js new file mode 100644 index 00000000..97f377c6 --- /dev/null +++ b/tests/packages/openapi.test.js @@ -0,0 +1,172 @@ +// Keeps packages/openapi.yaml honest against the router it describes: every route is either +// documented or explicitly excluded, the documented parameter rules are the ones the server +// enforces, and the spec is served. + +// None of these tests touch the package database. +jest.mock('sqlite3', () => ({ verbose: () => ({}) })); + +const express = require('express'); +const request = require('supertest'); +const PackagesModule = require('../../packages/packages'); +const openapi = require('../../packages/openapi'); +const packageJson = require('../../package.json'); + +// Routes the spec deliberately does not describe, with the reason. +const EXCLUDED = { + 'GET /': 'browser home page; its JSON form is the same search as /catalog', + 'GET /{page}.html': 'HTML pages', + 'GET /search': 'placeholder page, not implemented', + 'GET /log': 'operational: crawler log', + 'GET /stats': 'operational: server statistics', + 'GET /status': 'operational: module status', + 'POST /crawl': 'administrative', + 'GET /openapi': 'the description itself', + 'GET /openapi.json': 'the description itself', + 'GET /openapi.yaml': 'the description itself', + 'ALL {*splat}': 'catch-all 404' +}; + +const { + operations, parametersOf, describeSpecBasics, describeRouterAgreement +} = require('../utils/openapi-helpers'); + +function makeModule() { + const module = new PackagesModule({ countRequest() {} }); + module.config = { database: 'test.db', mirrorPath: '/nonexistent', crawler: { enabled: false } }; + return module; +} + +function makeApp(module) { + const app = express(); + app.use('/packages', module.router); + return app; +} + +// Checks that the documented parameters are exactly the ones the server validates, with the +// same length limits, patterns and defaults. +function expectParametersMatch(spec, operationId, location, rules) { + const params = parametersOf(spec, operationId, location); + expect(params.map(p => p.name).sort()).toEqual(Object.keys(rules).sort()); + for (const p of params) { + const rule = rules[p.name]; + expect({ name: p.name, maxLength: p.schema.maxLength }) + .toEqual({ name: p.name, maxLength: rule.maxLength }); + expect({ name: p.name, pattern: new RegExp(p.schema.pattern).source }) + .toEqual({ name: p.name, pattern: rule.pattern.source }); + if (rule.default !== undefined) { + expect({ name: p.name, default: p.schema.default }).toEqual({ name: p.name, default: rule.default }); + } + } +} + +describe('package server OpenAPI description', () => { + const spec = openapi.getSpec(); + + describe('the document', () => { + describeSpecBasics(spec, packageJson); + }); + + describe('agreement with the router', () => { + const module = makeModule(); + + describeRouterAgreement(spec, module.router, EXCLUDED); + + test('search parameters match the server validation rules', () => { + expectParametersMatch(spec, 'searchCatalog', 'query', PackagesModule.QUERY_PARAMS.search); + expectParametersMatch(spec, 'searchV1', 'query', PackagesModule.QUERY_PARAMS.v1Search); + }); + + test('updates parameters match the server validation rules', () => { + expectParametersMatch(spec, 'listUpdates', 'query', PackagesModule.QUERY_PARAMS.updates); + }); + + test('broken-dependency parameters match the server validation rules', () => { + expectParametersMatch(spec, 'listBrokenDependencies', 'query', PackagesModule.QUERY_PARAMS.broken); + }); + + test('download path parameters match the server validation rules', () => { + expectParametersMatch(spec, 'downloadPackage', 'path', PackagesModule.PATH_PARAMS); + }); + }); + + describe('serving', () => { + const app = makeApp(makeModule()); + + test('GET /packages/openapi.json returns the description', async () => { + const res = await request(app).get('/packages/openapi.json'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.body.openapi).toBe(spec.openapi); + expect(Object.keys(res.body.paths)).toEqual(Object.keys(spec.paths)); + }); + + test('GET /packages/openapi.yaml returns the YAML source', async () => { + const res = await request(app).get('/packages/openapi.yaml'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/yaml/); + expect(res.text).toContain('openapi: 3.1'); + }); + + test('GET /packages/openapi returns JSON to a non-browser client', async () => { + const res = await request(app).get('/packages/openapi').set('Accept', 'application/json'); + expect(res.status).toBe(200); + expect(res.body.info.title).toBe(spec.info.title); + }); + + test('the HTML reference lists every operation', () => { + const html = openapi.renderHtml(); + for (const { op } of Object.values(operations(spec))) { + expect(html).toContain(`id="${op.operationId}"`); + } + }); + + test('callers cannot modify the cached description', () => { + openapi.getSpec().info.title = 'changed'; + expect(openapi.getSpec().info.title).toBe(spec.info.title); + }); + }); + + // Regression: /:id is registered before these routes and used to return without + // calling next(), so they never responded. + describe('routes registered after /:id', () => { + const app = makeApp(makeModule()); + + test('GET /packages/status responds', async () => { + const res = await request(app).get('/packages/status').timeout(5000); + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(true); + expect(JSON.stringify(res.body)).not.toContain('/nonexistent'); + }); + + test('GET /packages/search responds', async () => { + const res = await request(app).get('/packages/search').set('Accept', 'application/json').timeout(5000); + expect(res.status).toBe(200); + }); + }); +}); + +describe('discovery', () => { + const app = makeApp(makeModule()); + + test('responses carry a Link header pointing at the description', async () => { + const res = await request(app).get('/packages/openapi.json'); + expect(res.headers.link).toContain('; rel="service-desc"'); + expect(res.headers.link).toContain('; rel="service-doc"'); + }); + + test('the packages page template links to the API reference and the description', () => { + const template = require('fs').readFileSync(require('path').join(__dirname, '../../packages/packages-template.html'), 'utf8'); + expect(template).toContain('href="/packages/openapi"'); + expect(template).toContain('rel="service-desc"'); + }); +}); + +describe('page footer version', () => { + test('[%ver%] is the FHIRsmith version, whatever the caller passes', () => { + const htmlServer = require('../../library/html-server'); + htmlServer.loadTemplate('ver-test', require('path').join(__dirname, '../../packages/packages-template.html')); + const html = htmlServer.renderPage('ver-test', 'Test', '

    x

    ', { version: '4.0.1' }); + expect(html).toContain(`FHIRsmith ${packageJson.version}`); + expect(html).not.toContain('FHIRsmith 4.0.1'); + }); +}); diff --git a/tests/packages/search.test.js b/tests/packages/search.test.js new file mode 100644 index 00000000..62f0ce16 --- /dev/null +++ b/tests/packages/search.test.js @@ -0,0 +1,287 @@ +// Search and package-document behaviour of the package server, against an in-memory +// database built with the module's own schema. + +const express = require('express'); +const request = require('supertest'); +const sqlite3 = require('sqlite3'); +const PackagesModule = require('../../packages/packages'); + +// [id, version, kind, fhirVersion, canonical, description, dependencies, pubDate, current] +const FIXTURES = [ + ['hl7.fhir.r4.core', '4.0.1', 0, '4.0.1', 'http://hl7.org/fhir', 'FHIR R4 core definitions', [], '2019-11-01', true], + ['hl7.fhir.r4b.core', '4.3.0', 0, '4.3.0', 'http://hl7.org/fhir', 'FHIR R4B core definitions', [], '2022-05-28', true], + ['hl7.fhir.uv.ips', '1.1.0', 1, '4.0.1', 'http://hl7.org/fhir/uv/ips', 'International Patient Summary', ['hl7.fhir.r4.core@4.0.1'], '2022-11-01', true], + ['hl7.fhir.uv.ips', '2.0.0-ballot', 1, '4.0.1', 'http://hl7.org/fhir/uv/ips', 'International Patient Summary', ['hl7.fhir.r4.core@4.0.1'], '2024-09-01', false], + ['hl7.fhir.uv.extensions.r4b', '5.1.0', 1, '4.3.0', 'http://hl7.org/fhir/extensions', 'Extensions for R4B', ['hl7.fhir.r4b.core@4.3.0'], '2023-03-01', true], + ['hl7.fhir.r6.core', '6.0.0-ballot3', 0, '6.0.0-ballot3', 'http://hl7.org/fhir', 'FHIR R6 ballot', [], '2025-06-01', true], + ['@example/scoped', '1.0.0', 2, '5.0.0', 'http://example.org/scoped', 'A scoped template', [], '2025-01-01', true] +]; + +function run(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, function (err) { + if (err) { + reject(err); + } else { + resolve(this.lastID); + } + }); + }); +} + +async function buildModule(configOverrides = {}) { + const module = new PackagesModule({ countRequest() {} }); + module.config = { database: ':memory:', mirrorPath: '/nonexistent', crawler: { enabled: false }, ...configOverrides }; + module.db = new sqlite3.Database(':memory:'); + await module.createTables(); + + const current = {}; + for (const [id, version, kind, fhirVersion, canonical, description, deps, pubDate, isCurrent] of FIXTURES) { + const key = await run(module.db, + `INSERT INTO PackageVersions (GUID, PubDate, Indexed, Id, Version, Kind, DownloadCount, Canonical, + FhirVersions, Hash, Author, License, HomePage, Description, Content) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, 'hash', 'HL7', 'CC0-1.0', '', ?, ?)`, + [`${id}#${version}`, pubDate, pubDate, id, version, kind, canonical, fhirVersion, + Buffer.from(description, 'utf8'), Buffer.from('tgz')]); + await run(module.db, 'INSERT INTO PackageFHIRVersions (PackageVersionKey, Version) VALUES (?, ?)', [key, fhirVersion]); + for (const dep of deps) { + await run(module.db, 'INSERT INTO PackageDependencies (PackageVersionKey, Dependency) VALUES (?, ?)', [key, dep]); + } + if (isCurrent) { + current[id] = { key, canonical }; + } + } + for (const [id, { key, canonical }] of Object.entries(current)) { + await run(module.db, + 'INSERT INTO Packages (Id, Canonical, DownloadCount, CurrentVersion) VALUES (?, ?, 5, ?)', [id, canonical, key]); + } + return module; +} + +function appFor(module) { + const app = express(); + app.use('/packages', module.router); + return app; +} + +const names = body => body.map(p => p.name); + +describe('package server search', () => { + let module; + let app; + + beforeAll(async () => { + module = await buildModule(); + app = appFor(module); + }); + + afterAll(() => module.db.close()); + + const catalog = query => request(app).get('/packages/catalog').query(query).set('Accept', 'application/json'); + + describe('dependency and dependson (stored as id@version)', () => { + test.each([ + ['hl7.fhir.r4.core'], + ['hl7.fhir.r4.core|4.0.1'], + ['hl7.fhir.r4.core#4.0.1'], + ['hl7.fhir.r4.core@4.0'], + ['hl7.fhir.r4.core#4.0'] + ])('dependency=%s finds the dependents', async dependency => { + const res = await catalog({ dependency }); + expect(res.status).toBe(200); + expect(names(res.body)).toEqual(['hl7.fhir.uv.ips']); + }); + + test('dependency on another version finds nothing', async () => { + const res = await catalog({ dependency: 'hl7.fhir.r4.core|3.0.2' }); + expect(res.body).toEqual([]); + }); + + test('dependency does not match a package whose id merely starts the same', async () => { + const res = await catalog({ dependency: 'hl7.fhir.r4' }); + expect(res.body).toEqual([]); + }); + + test('dependson with a version matches every version that has the dependency', async () => { + const res = await catalog({ dependson: 'hl7.fhir.r4.core#4.0.1' }); + expect(res.body.map(p => `${p.name}#${p.version}`).sort()) + .toEqual(['hl7.fhir.uv.ips#1.1.0', 'hl7.fhir.uv.ips#2.0.0-ballot']); + }); + }); + + describe('fhirversion', () => { + test.each([ + ['R4', ['hl7.fhir.r4.core', 'hl7.fhir.uv.ips']], + ['R4B', ['hl7.fhir.r4b.core', 'hl7.fhir.uv.extensions.r4b']], + ['R5', ['@example/scoped']], + ['R6', ['hl7.fhir.r6.core']] + ])('fhirversion=%s', async (fhirversion, expected) => { + const res = await catalog({ fhirversion }); + expect(res.status).toBe(200); + expect(names(res.body).sort()).toEqual(expected); + }); + }); + + describe('sort', () => { + test('sort=fhirversion orders by FHIR version, pre-releases first', async () => { + const res = await catalog({ sort: 'fhirversion' }); + expect(res.body.map(p => p.fhirVersion)) + .toEqual(['4.0.1', '4.0.1', '4.3.0', '4.3.0', '5.0.0', '6.0.0-ballot3']); + }); + + test('sort=-kind orders by kind, descending', async () => { + const res = await catalog({ sort: '-kind' }); + const kinds = res.body.map(p => p.kind); + expect(kinds).toEqual([...kinds].sort().reverse()); + expect(kinds[0]).toBe('fhir.template'); + }); + + test('sort=canonical orders by canonical URL', async () => { + const res = await catalog({ sort: 'canonical' }); + const canonicals = res.body.map(p => p.canonical); + expect(canonicals).toEqual([...canonicals].sort()); + }); + + test('sort=version puts a pre-release before its release', async () => { + const res = await catalog({ name: 'hl7.fhir.uv.ips#', sort: 'version' }); + expect(res.body.map(p => p.version)).toEqual(['1.1.0', '2.0.0-ballot']); + }); + }); + + describe('objWrapper and prerelease', () => { + test('objWrapper=false returns a plain array', async () => { + const res = await catalog({ name: 'hl7.fhir.uv.ips', objWrapper: 'false' }); + expect(Array.isArray(res.body)).toBe(true); + }); + + test('objWrapper=true wraps the results', async () => { + const res = await catalog({ name: 'hl7.fhir.uv.ips', objWrapper: 'true' }); + expect(res.body.objects.map(o => o.package.name)).toEqual(['hl7.fhir.uv.ips']); + }); + + test('prerelease (sent by the Java PackageClient) is accepted', async () => { + const res = await catalog({ name: 'hl7.fhir.uv.ips', prerelease: 'true' }); + expect(res.status).toBe(200); + expect(names(res.body)).toEqual(['hl7.fhir.uv.ips']); + }); + }); + + describe('/-/v1/search (npm)', () => { + const v1 = query => request(app).get('/packages/-/v1/search').query(query).set('Accept', 'application/json'); + + test('accepts the parameters the npm CLI sends', async () => { + const res = await v1({ text: 'patient summary', size: '20', from: '0', quality: '0.65', popularity: '0.98', maintenance: '0.5' }); + expect(res.status).toBe(200); + expect(res.body.total).toBe(1); + expect(res.body.objects[0].package.name).toBe('hl7.fhir.uv.ips'); + expect(typeof res.body.time).toBe('string'); + }); + + test('results carry the fields the npm CLI reads', async () => { + const res = await v1({ text: 'ips' }); + const entry = res.body.objects[0]; + expect(entry.package.maintainers).toEqual([]); + expect(entry.package.keywords).toEqual([]); + expect(entry.score.final).toBe(1); + expect(entry.searchScore).toBe(1); + }); + + test('every text term must match', async () => { + expect((await v1({ text: 'core R4B' })).body.objects.map(o => o.package.name)).toEqual(['hl7.fhir.r4b.core']); + expect((await v1({ text: 'core nothing-matches' })).body.total).toBe(0); + }); + + test('npm qualifiers are ignored', async () => { + const res = await v1({ text: 'keywords:fhir ips' }); + expect(res.body.objects.map(o => o.package.name)).toEqual(['hl7.fhir.uv.ips']); + }); + + test('size and from page the results; total counts them all', async () => { + const all = await v1({ sort: 'name' }); + const page = await v1({ sort: 'name', size: '2', from: '1' }); + expect(page.body.total).toBe(all.body.total); + expect(page.body.objects.map(o => o.package.name)) + .toEqual(all.body.objects.slice(1, 3).map(o => o.package.name)); + }); + + test('combines with the FHIR search parameters', async () => { + const res = await v1({ text: 'core', fhirversion: 'R4' }); + expect(res.body.objects.map(o => o.package.name)).toEqual(['hl7.fhir.r4.core']); + }); + }); + + describe('package document', () => { + test('version _id is id@version', async () => { + const res = await request(app).get('/packages/hl7.fhir.uv.ips').set('Accept', 'application/json'); + expect(res.status).toBe(200); + expect(res.body.versions['1.1.0']._id).toBe('hl7.fhir.uv.ips@1.1.0'); + expect(res.body.versions['2.0.0-ballot']._id).toBe('hl7.fhir.uv.ips@2.0.0-ballot'); + }); + }); +}); + +describe('package server with bucket storage', () => { + let module; + let app; + + beforeAll(async () => { + module = await buildModule({ bucketPath: 'https://bucket.example.org/packages' }); + app = appFor(module); + }); + + afterAll(() => module.db.close()); + + test('tarball and search URLs for a scoped package use the mirror file name', async () => { + const doc = await request(app).get('/packages/%40example%2Fscoped').set('Accept', 'application/json'); + expect(doc.status).toBe(200); + expect(doc.body.versions['1.0.0'].dist.tarball).toBe('https://bucket.example.org/packages/$example$scoped-1.0.0.tgz'); + + const search = await request(app).get('/packages/catalog').query({ name: 'scoped' }).set('Accept', 'application/json'); + expect(search.body[0].url).toBe('https://bucket.example.org/packages/$example$scoped-1.0.0.tgz'); + }); + + test('tarball URL for an unscoped package is unchanged', async () => { + const doc = await request(app).get('/packages/hl7.fhir.uv.ips').set('Accept', 'application/json'); + expect(doc.body.versions['1.1.0'].dist.tarball).toBe('https://bucket.example.org/packages/hl7.fhir.uv.ips-1.1.0.tgz'); + }); +}); + +describe('compareVersions', () => { + const module = new PackagesModule({ countRequest() {} }); + + test('orders numerically, with pre-releases before their release', () => { + const versions = ['1.10.0', '1.0.0', '1.0.0-ballot', '1.2.0', '1.0.0-ballot2', '0.9']; + expect(versions.sort((a, b) => module.compareVersions(a, b))) + .toEqual(['0.9', '1.0.0-ballot', '1.0.0-ballot2', '1.0.0', '1.2.0', '1.10.0']); + }); + + test('never returns NaN', () => { + expect(Number.isNaN(module.compareVersions('current', '1.0.0'))).toBe(false); + }); +}); + +describe('database age', () => { + const folders = require('../../library/folder-setup'); + const fs = require('fs'); + const path = require('path'); + + test('a relative database path is found in the data directory, as initializeDatabase opens it', () => { + const name = `age-test-${process.pid}.db`; + const file = folders.filePath('packages', name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, ''); + try { + const module = new PackagesModule({ countRequest() {} }); + module.config = { database: name, crawler: { enabled: false } }; + expect(module.getDatabaseAgeInfo().status).toBe('Today'); + } finally { + fs.unlinkSync(file); + } + }); + + test('a missing database file is reported', () => { + const module = new PackagesModule({ countRequest() {} }); + module.config = { database: `missing-${process.pid}.db`, crawler: { enabled: false } }; + expect(module.getDatabaseAgeInfo().status).toBe('No database file'); + }); +}); diff --git a/tests/registry/openapi.test.js b/tests/registry/openapi.test.js new file mode 100644 index 00000000..0becc0ee --- /dev/null +++ b/tests/registry/openapi.test.js @@ -0,0 +1,201 @@ +// Keeps registry/openapi.yaml honest against the router it describes, and covers the +// Discovery API and the resolve changes that came with it. + +const fs = require('fs'); +const path = require('path'); +const express = require('express'); +const request = require('supertest'); +const RegistryModule = require('../../registry/registry'); +const RegistryCrawler = require('../../registry/crawler'); +const RegistryAPI = require('../../registry/api'); +const openapi = require('../../registry/openapi'); +const packageJson = require('../../package.json'); +const { parametersOf, operations, describeSpecBasics, describeRouterAgreement } = require('../utils/openapi-helpers'); + +// Routes the spec deliberately does not describe, with the reason. +const EXCLUDED = { + 'GET /log': 'operational: crawler log', + 'GET /openapi': 'the description itself', + 'GET /openapi.json': 'the description itself', + 'GET /openapi.yaml': 'the description itself' +}; + +function makeModule() { + const module = new RegistryModule({ countRequest() {} }); + module.crawler = new RegistryCrawler(); + module.crawler.loadData(JSON.parse(fs.readFileSync(path.join(__dirname, 'test-data.json'), 'utf8'))); + module.api = new RegistryAPI(module.crawler); + module.setupRoutes(); + return module; +} + +function makeApp(module) { + const app = express(); + app.use('/tx-reg', module.router); + return app; +} + +const urls = list => (list || []).map(e => e.url); + +describe('registry OpenAPI description', () => { + const spec = openapi.getSpec(); + const module = makeModule(); + const app = makeApp(module); + + describe('the document', () => { + describeSpecBasics(spec, packageJson); + }); + + describe('agreement with the router', () => { + describeRouterAgreement(spec, module.router, EXCLUDED); + + test('discovery parameters are the ones the handler reads', () => { + expect(parametersOf(spec, 'discover', 'query').map(p => p.name).sort()) + .toEqual([...RegistryModule.DISCOVERY_PARAMS].sort()); + }); + + test('resolve parameters are the ones the handler reads', () => { + expect(parametersOf(spec, 'resolve', 'query').map(p => p.name).sort()) + .toEqual([...RegistryModule.RESOLVE_PARAMS].sort()); + }); + }); + + describe('serving and discovery links', () => { + test('GET /tx-reg/openapi.json returns the description', async () => { + const res = await request(app).get('/tx-reg/openapi.json'); + expect(res.status).toBe(200); + expect(Object.keys(res.body.paths)).toEqual(Object.keys(spec.paths)); + }); + + test('GET /tx-reg/openapi.yaml returns the YAML source', async () => { + const res = await request(app).get('/tx-reg/openapi.yaml'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/yaml/); + }); + + test('GET /tx-reg/openapi returns JSON to a non-browser client', async () => { + const res = await request(app).get('/tx-reg/openapi').set('Accept', 'application/json'); + expect(res.body.info.title).toBe(spec.info.title); + }); + + test('the HTML reference lists every operation', () => { + const html = openapi.renderHtml(); + for (const { op } of Object.values(operations(spec))) { + expect(html).toContain(`id="${op.operationId}"`); + } + }); + + test('responses carry a Link header pointing at the description', async () => { + const res = await request(app).get('/tx-reg/openapi.json'); + expect(res.headers.link).toContain('; rel="service-desc"'); + }); + + test('the registry page template links to the API reference and the description', () => { + const template = fs.readFileSync(path.join(__dirname, '../../registry/registry-template.html'), 'utf8'); + expect(template).toContain('href="/tx-reg/openapi"'); + expect(template).toContain('rel="service-desc"'); + }); + }); +}); + +describe('registry Discovery API', () => { + const app = makeApp(makeModule()); + const discover = query => request(app).get('/tx-reg/').query(query).set('Accept', 'application/json'); + + test('lists every endpoint, in the shape the ecosystem IG describes', async () => { + const res = await discover({}); + expect(res.status).toBe(200); + expect(res.body['master-url']).toBe('https://fhir.github.io/ig-registry/tx-servers.json'); + expect(res.body).toHaveProperty('last-update'); + expect(res.body.results.length).toBe(12); + const tx = res.body.results.find(r => r.url === 'http://tx.fhir.org/r4'); + expect(tx).toMatchObject({ + 'server-name': 'tx.fhir.org', + 'server-code': 'tx.fhir.org', + fhirVersion: '4.0.1', + open: true, + security: 'open' + }); + expect(typeof tx.systems).toBe('number'); + expect(Array.isArray(tx.authoritative)).toBe(true); + expect(tx).not.toHaveProperty('candidate'); + }); + + test('filters by server and FHIR version', async () => { + expect(urls((await discover({ server: 'tx.fhir.org' })).body.results).sort()) + .toEqual(['http://tx.fhir.org/r3', 'http://tx.fhir.org/r4', 'http://tx.fhir.org/r5']); + expect(urls((await discover({ server: 'tx.fhir.org', fhirVersion: 'R5' })).body.results)) + .toEqual(['http://tx.fhir.org/r5']); + }); + + test('filters by registry', async () => { + const all = (await discover({})).body.results; + const code = all[0]['registry-code']; + const filtered = (await discover({ registry: code })).body.results; + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.every(r => r['registry-code'] === code)).toBe(true); + expect((await discover({ registry: 'no-such-registry' })).body.results).toEqual([]); + }); + + test('with url, lists only endpoints that host it, authoritative first, others marked as candidates', async () => { + const res = await discover({ url: 'http://snomed.info/sct|http://snomed.info/sct/32506021000036107', fhirVersion: 'R4' }); + const results = res.body.results; + expect(results[0].url).toBe('https://tx.ontoserver.csiro.au/fhir'); + expect(results[0]).not.toHaveProperty('candidate'); + const tx = results.find(r => r.url === 'http://tx.fhir.org/r4'); + expect(tx.candidate).toEqual(['http://snomed.info/sct|http://snomed.info/sct/32506021000036107']); + }); + + test('authoritativeOnly keeps only the authoritative endpoints', async () => { + const res = await discover({ url: 'http://snomed.info/sct|http://snomed.info/sct/32506021000036107', fhirVersion: 'R4', authoritativeOnly: 'true' }); + expect(urls(res.body.results)).toEqual(['https://tx.ontoserver.csiro.au/fhir']); + }); + + test('a malformed url is a 400', async () => { + const res = await discover({ url: 'not a url' }); + expect(res.status).toBe(400); + }); + + test('a browser still gets the HTML page', async () => { + const res = await request(app).get('/tx-reg/').set('Accept', 'text/html'); + expect(res.headers['content-type']).toMatch(/text\/html/); + }); +}); + +describe('registry resolve', () => { + const module = makeModule(); + const api = module.api; + + test('entries carry fhirVersion and the security flags', () => { + const { result } = api.resolveCodeSystem('R4', 'http://loinc.org', false); + const all = [...(result.authoritative || []), ...(result.candidates || [])]; + expect(all.length).toBeGreaterThan(0); + for (const entry of all) { + expect(entry.fhirVersion).toMatch(/^4\.0/); + if (entry.security === 'open') { + expect(entry.open).toBe(true); + } else if (entry.security === 'api-key') { + expect(entry.token).toBe(true); + } + } + }); + + test.each([ + ['R4', '4.0'], ['r4', '4.0'], ['R4B', '4.3'], ['R2B', '1.4'], ['R5', '5.0'], ['R6', '6.0'], ['4.0.1', '4.0.1'] + ])('FHIR version %s selects %s', (given, expected) => { + expect(api._normalizeFhirVersion(given)).toBe(expected); + }); + + test('a SNOMED CT edition is hosted by a server that hosts a version of it', () => { + // the Canadian server lists only full versions (.../20611000087101/version/...) + const { result } = api.resolveCodeSystem('R4', 'http://snomed.info/sct|http://snomed.info/sct/20611000087101', false); + expect(urls(result.authoritative)).toContain('https://terminologystandardsservice.ca/tx/fhir'); + }); + + test('a SNOMED CT edition does not match a different edition with the same prefix', () => { + // .../sct/1 must not match .../sct/11000146104/version/... + const { result } = api.resolveCodeSystem('R4', 'http://snomed.info/sct|http://snomed.info/sct/1', false); + expect(result.authoritative).toBeUndefined(); + expect(result.candidates).toBeUndefined(); + }); +}); diff --git a/tests/registry/registry-exclusions.test.js b/tests/registry/registry-exclusions.test.js index cbd4aab8..071860c9 100644 --- a/tests/registry/registry-exclusions.test.js +++ b/tests/registry/registry-exclusions.test.js @@ -43,7 +43,7 @@ function makeServer(code, name, address, { authCS = [], authVS = [], languages = // m: wildcard claims covering both CS urls, excludes CS_HIDDEN, but (simulating stale // persisted data) still lists CS_HIDDEN as hosted - exclusion must win anyway // f: wildcard claim covering CS_HIDDEN, excludes it, hosts nothing - previously leaked -// back in via the resolve fallback path +// back in via the (since removed) resolve fallback path // g: language specific claim covering CS_HIDDEN for de, excludes it function createData() { const data = new ServerRegistries(); @@ -145,14 +145,15 @@ describe('Exclusions hide the server entirely', () => { expect(urls(result.authoritative)).toContain('https://m.example.org/r4'); }); - test('resolve fallback path cannot resurrect an excluded code system', () => { - // f claims http://example.org/cs/* but hosts nothing; before the fix the fallback - // ("no matches anywhere -> use authoritative masks") returned it for CS_HIDDEN + test('a server that claims authority but does not host the code system is not returned', () => { + // f claims http://example.org/cs/* but hosts nothing. There used to be a fallback + // ("no matches anywhere -> use authoritative masks"); the ecosystem IG says servers + // are not listed as authoritative unless they actually host the code system const { result } = api.resolveCodeSystem('R4', CS_HIDDEN, false); expect(urls(result.authoritative)).not.toContain('https://f.example.org/r4'); - // but the fallback still works for a non-excluded, non-hosted code system - const ok = api.resolveCodeSystem('R4', 'http://example.org/cs/unhosted', false).result; - expect(urls(ok.authoritative)).toContain('https://f.example.org/r4'); + const unhosted = api.resolveCodeSystem('R4', 'http://example.org/cs/unhosted', false).result; + expect(urls(unhosted.authoritative)).toEqual([]); + expect(urls(unhosted.candidates)).toEqual([]); }); test('exclusion defeats language routing too', () => { diff --git a/tests/registry/registry-resolve.test.js b/tests/registry/registry-resolve.test.js index 10804571..73b2e1ff 100644 --- a/tests/registry/registry-resolve.test.js +++ b/tests/registry/registry-resolve.test.js @@ -101,6 +101,19 @@ describe('Registry Resolve Functional Tests', () => { "server-name": "HL7 Australia Server", "url": "https://tx.ontoserver.csiro.au/fhir", "security": "open", + "fhirVersion": "4.0.1", + "open": true, + "access_info": "This server is open to the public" + } + ], + // tx.fhir.org hosts versions of the Australian edition too, so it's a candidate + "candidates": [ + { + "server-name": "tx.fhir.org", + "url": "http://tx.fhir.org/r4", + "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "This server is open to the public" } ] @@ -121,6 +134,8 @@ describe('Registry Resolve Functional Tests', () => { "server-name": "HL7 Australia Server", "url": "https://tx.ontoserver.csiro.au/fhir", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "This server is open to the public" } ] @@ -140,30 +155,40 @@ describe('Registry Resolve Functional Tests', () => { "server-name": "tx.fhir.org", "url": "http://tx.fhir.org/r4", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "This server is open to the public" }, { "server-name": "HL7 Australia Server", "url": "https://tx.ontoserver.csiro.au/fhir", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "This server is open to the public" }, { "server-name": "HL7 Europe Terminology Server", "url": "http://tx.hl7europe.eu/r4", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "Open" }, { "server-name": "HL7 Switzerland Terminology Server", "url": "https://tx.fhir.ch/r4", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "Open" }, { "server-name": "New Zealand Health Terminology Service (NZHTS)", "url": "https://nzhts.digital.health.nz/fhir", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "This server requires an API Key - see https://www.tewhatuora.govt.nz/health-services-and-programmes/digital-health/terminology-service" } ] @@ -183,30 +208,40 @@ describe('Registry Resolve Functional Tests', () => { "server-name": "tx.fhir.org", "url": "http://tx.fhir.org/r4", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "This server is open to the public" }, { "server-name": "HL7 Australia Server", "url": "https://tx.ontoserver.csiro.au/fhir", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "This server is open to the public" }, { "server-name": "Canada Health Infoway Terminology Server", "url": "https://terminologystandardsservice.ca/tx/fhir", "security": "api-key", + "fhirVersion": "4.0.1", + "token": true, "access_info": "This server requires an API Key - see https://infocentral.infoway-inforoute.ca/en/tools/standards-tools/terminology-server" }, { "server-name": "HL7 Europe Terminology Server", "url": "http://tx.hl7europe.eu/r4", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "Open" }, { "server-name": "HL7 Switzerland Terminology Server", "url": "https://tx.fhir.ch/r4", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "Open" } ] @@ -226,18 +261,24 @@ describe('Registry Resolve Functional Tests', () => { "server-name": "tx.fhir.org", "url": "http://tx.fhir.org/r5", "security": "open", + "fhirVersion": "5.0.0", + "open": true, "access_info": "This server is open to the public" }, { "server-name": "HL7 Europe Terminology Server", "url": "http://tx.hl7europe.eu/r5", "security": "open", + "fhirVersion": "5.0.0", + "open": true, "access_info": "Open" }, { "server-name": "TEHIK Terminology Server", "url": "https://term.tehik.ee/fhir", "security": "open", + "fhirVersion": "5.0.0", + "open": true, "access_info": "Open" } ] @@ -250,8 +291,28 @@ describe('Registry Resolve Functional Tests', () => { fhirVersion: '4.0', url: 'http://snomed.info/sct|http://snomed.info/sct/11000172109', expected: { + // The Belgian server is authoritative but restricted to publication use; the + // servers that host versions of the Belgian edition are still candidates "formatVersion": "1", - "registry-url": "https://fhir.github.io/ig-registry/tx-servers.json" + "registry-url": "https://fhir.github.io/ig-registry/tx-servers.json", + "candidates": [ + { + "server-name": "tx.fhir.org", + "url": "http://tx.fhir.org/r4", + "security": "open", + "fhirVersion": "4.0.1", + "open": true, + "access_info": "This server is open to the public" + }, + { + "server-name": "HL7 Australia Server", + "url": "https://tx.ontoserver.csiro.au/fhir", + "security": "open", + "fhirVersion": "4.0.1", + "open": true, + "access_info": "This server is open to the public" + } + ] } }); }); @@ -262,8 +323,28 @@ describe('Registry Resolve Functional Tests', () => { url: 'http://snomed.info/sct|http://snomed.info/sct/11000172109', usage: "validation", expected: { + // The Belgian server is authoritative but restricted to publication use; the + // servers that host versions of the Belgian edition are still candidates "formatVersion": "1", - "registry-url": "https://fhir.github.io/ig-registry/tx-servers.json" + "registry-url": "https://fhir.github.io/ig-registry/tx-servers.json", + "candidates": [ + { + "server-name": "tx.fhir.org", + "url": "http://tx.fhir.org/r4", + "security": "open", + "fhirVersion": "4.0.1", + "open": true, + "access_info": "This server is open to the public" + }, + { + "server-name": "HL7 Australia Server", + "url": "https://tx.ontoserver.csiro.au/fhir", + "security": "open", + "fhirVersion": "4.0.1", + "open": true, + "access_info": "This server is open to the public" + } + ] } }); }); @@ -281,8 +362,28 @@ describe('Registry Resolve Functional Tests', () => { "server-name": "Federal Public Service Health, Food Chain Safety and Environment", "url": "https://apps.health.belgium.be/ontoserver/fhir", "security": "open", + "fhirVersion": "4.0.1", + "open": true, "access_info": "This server is open to publishers of IGs" } + ], + "candidates": [ + { + "server-name": "tx.fhir.org", + "url": "http://tx.fhir.org/r4", + "security": "open", + "fhirVersion": "4.0.1", + "open": true, + "access_info": "This server is open to the public" + }, + { + "server-name": "HL7 Australia Server", + "url": "https://tx.ontoserver.csiro.au/fhir", + "security": "open", + "fhirVersion": "4.0.1", + "open": true, + "access_info": "This server is open to the public" + } ] } }); diff --git a/tests/server/openapi-doc.test.js b/tests/server/openapi-doc.test.js new file mode 100644 index 00000000..98ec21bb --- /dev/null +++ b/tests/server/openapi-doc.test.js @@ -0,0 +1,92 @@ +// The shared OpenAPI page renderer (library/openapi-doc.js): the "try it" forms. + +const { buildTryItRequest, curlCommand } = require('../../library/openapi-doc'); +const packagesOpenApi = require('../../packages/openapi'); +const testingOpenApi = require('../../testing/openapi'); + +describe('buildTryItRequest', () => { + test('fills in path parameters, encoded', () => { + const r = buildTryItRequest('/packages/{id}/{version}', [ + { in: 'path', name: 'id', value: '@scope/name' }, + { in: 'path', name: 'version', value: '1.0.0' } + ]); + expect(r).toEqual({ url: '/packages/%40scope%2Fname/1.0.0', headers: {}, missing: [] }); + }); + + test('reports missing path parameters', () => { + expect(buildTryItRequest('/x/{id}', [{ in: 'path', name: 'id', value: ' ' }]).missing).toEqual(['id']); + }); + + test('adds non-empty query parameters, encoded, and headers', () => { + const r = buildTryItRequest('/tx-reg/resolve', [ + { in: 'query', name: 'fhirVersion', value: 'R4' }, + { in: 'query', name: 'url', value: 'http://snomed.info/sct|http://snomed.info/sct/32506021000036107' }, + { in: 'query', name: 'usage', value: '' }, + { in: 'header', name: 'Prefer', value: 'handling=strict' } + ]); + expect(r.url).toBe('/tx-reg/resolve?fhirVersion=R4&url=http%3A%2F%2Fsnomed.info%2Fsct%7Chttp%3A%2F%2Fsnomed.info%2Fsct%2F32506021000036107'); + expect(r.headers).toEqual({ Prefer: 'handling=strict' }); + }); + + test('keeps modifiers in parameter names', () => { + expect(buildTryItRequest('/testing/TestReport', [{ in: 'query', name: 'name:exact', value: 'x' }]).url) + .toBe('/testing/TestReport?name%3Aexact=x'); + }); +}); + +describe('curlCommand', () => { + test('quotes for a POSIX shell', () => { + expect(curlCommand('GET', "http://h/x?q=it's", { Accept: 'application/json' })) + .toBe("curl -H 'Accept: application/json' 'http://h/x?q=it'\\''s'"); + }); + + test('POST with a body file', () => { + expect(curlCommand('POST', 'http://h/TestReport', { 'Content-Type': 'application/fhir+json' }, 'body.json')) + .toBe("curl -X POST -H 'Content-Type: application/fhir+json' --data-binary @body.json 'http://h/TestReport'"); + }); +}); + +describe('the rendered reference page', () => { + test('every GET operation has a try-it form asking for JSON; POST has an example only', () => { + const html = testingOpenApi.renderHtml(); + expect(html.match(/