diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d71154d..b3246bec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,10 @@ jobs: name: test-results path: ./test-results/junit.xml + # Coverage is informational: a failed upload (codecov down, TLS trouble) mustn't fail the build - name: Upload coverage reports - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 + continue-on-error: true with: directory: ./coverage/ token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pr-pipeline.yml b/.github/workflows/pr-pipeline.yml index 5f6be8ad..7cc0b7a7 100644 --- a/.github/workflows/pr-pipeline.yml +++ b/.github/workflows/pr-pipeline.yml @@ -61,9 +61,11 @@ jobs: - name: Check for known vulnerabilities run: | - # Check for high/critical vulnerabilities only + # Check for high/critical vulnerabilities only, in what ships (the image is built with + # npm ci --omit=dev). Dev tooling is reported by the step above, but doesn't fail the + # build: jest and nodemon depend on braces, which has an advisory with no fixed version. # npm audit returns non-zero exit code if vulnerabilities are found at the specified level - if npm audit --audit-level=high; then + if npm audit --omit=dev --audit-level=high; then echo "No high or critical vulnerabilities found" else echo "High or critical vulnerabilities found!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efc50e1d..69769b66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,8 +73,10 @@ jobs: name: test-results path: ./test-results/junit.xml + # Coverage is informational: a failed upload (codecov down, TLS trouble) mustn't fail the build - name: Upload coverage reports - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 + continue-on-error: true with: directory: ./coverage/ token: ${{ secrets.CODECOV_TOKEN }} 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/library/fhir-openapi-schema.js b/library/fhir-openapi-schema.js new file mode 100644 index 00000000..60a1528a --- /dev/null +++ b/library/fhir-openapi-schema.js @@ -0,0 +1,441 @@ +// +// 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. + * + * A module can move the boundaries, when it knows what it handles: + * - `resources` names the resources an element of type Resource can hold, by path + * (Bundle.entry.resource: CodeSystem or ValueSet, say); they're generated too. An + * element whose type is a particular resource (Bundle.issues: OperationOutcome) is always + * that resource + * - `choiceTypes` restricts the types of a choice element, by path + * (Parameters.parameter.value[x]: the primitives, say) + * - `generateExtension` describes Extension fully, from its StructureDefinition, instead + * of as a boundary; usually with choiceTypes for Extension.value[x] + * + * 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; included where something reaches them. */ +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] - elements that are left out: a name (e.g. + * 'contained') leaves the element out of every resource and type, a path (e.g. + * 'CodeSystem.contained') out of that one place. The schemas are closed, so they're + * then not allowed + * @param {Object} [options.resources] - path -> the resource types an + * element of type Resource may hold (otherwise it's AnyResource) + * @param {Object} [options.choiceTypes] - path of a choice element + * (e.g. 'Parameters.parameter.value[x]') -> the types allowed (otherwise all of them) + * @param {boolean} [options.generateExtension] - describe Extension from its + * StructureDefinition rather than as a boundary + */ + constructor(pkg, options = {}) { + this.pkg = pkg; + this.prohibit = new Set(options.prohibit || []); + this.resources = options.resources || {}; + this.choiceTypes = options.choiceTypes || {}; + this.boundary = { ...BOUNDARY_SCHEMAS }; + if (options.generateExtension) { + delete this.boundary.Extension; + } + 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(this.boundary)); + roots.forEach(r => this.enqueue(r)); + while (this.queue.length > 0) { + this.generateType(this.queue.shift()); + } + // a boundary nothing ended up at isn't needed + const used = JSON.stringify(this.schemas); + for (const name of Object.keys(this.boundary)) { + if (!used.includes(`"#/components/schemas/${name}"`)) { + delete this.schemas[name]; + } + } + // 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) && !this.boundary[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) || this.prohibit.has(el.path)) { + continue; + } + if (name.endsWith('[x]')) { + const base = name.slice(0, -3); + const allowed = this.choiceTypes[el.path]; + if (allowed) { + const unknown = allowed.filter(t => !el.type.some(et => et.code === t)); + if (unknown.length > 0) { + throw new Error(`${el.path} can't be ${unknown.join(', ')}`); + } + } + for (const t of el.type.filter(t => !allowed || allowed.includes(t.code))) { + 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') { + this.enqueue('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 (type.code === 'Resource' || type.code === 'DomainResource') { + const allowed = this.resources[el.path]; + if (allowed) { + allowed.forEach(t => this.enqueue(t)); + value = allowed.length === 1 ? ref(allowed[0]) : { anyOf: allowed.map(ref) }; + } else { + value = ref('AnyResource'); + } + } else if (tsd.kind === 'resource') { + this.enqueue(type.code); + value = ref(type.code); + } 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. + */ +// keys that would reach an object's prototype rather than the object +function isUnsafeKey(k) { + return k === '__proto__' || k === 'constructor' || k === 'prototype'; +} + +function applyOverlay(schemas, overlay) { + const merge = (target, src) => { + for (const [k, v] of Object.entries(src)) { + if (isUnsafeKey(k)) { + throw new Error(`An overlay can't set '${k}'`); + } + // only the target's own properties are merged into, never anything it inherits + const own = Object.prototype.hasOwnProperty.call(target, k); + if (k === 'required' && Array.isArray(target.required)) { + target.required = [...new Set([...target.required, ...v])]; + } else if (own && 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 (isUnsafeKey(name)) { + throw new Error(`An overlay can't set '${name}'`); + } + if (Object.prototype.hasOwnProperty.call(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, resources, choiceTypes, + * generateExtension, overlay } (see FhirSchemaGenerator) + * @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, + resources: config.resources, + choiceTypes: config.choiceTypes, + generateExtension: config.generateExtension + }); + 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..358ab2d1 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())) @@ -82,7 +83,10 @@ class HtmlServer { .replace(/\[%sponsorMessage%\]/g, sponsorMessage) .replace(/\[%about%\]/g, renderOptions.about || '') // raw HTML, like [%about%]: the caller decides whether the nav item exists at all - .replace(/\[%library-link%\]/g, renderOptions.libraryLink || ''); + .replace(/\[%library-link%\]/g, renderOptions.libraryLink || '') + // raw HTML too: the OpenAPI description, where a template's module has one in some places only + .replace(/\[%api-link%\]/g, renderOptions.apiLink || '') + .replace(/\[%api-head%\]/g, renderOptions.apiHead || ''); // Handle any custom template variables if (options.templateVars) { diff --git a/library/openapi-doc.js b/library/openapi-doc.js new file mode 100644 index 00000000..2c8a8601 --- /dev/null +++ b/library/openapi-doc.js @@ -0,0 +1,557 @@ +// +// 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 commonmarkHtml(text) { + const reader = new commonmark.Parser(); + const writer = new commonmark.HtmlRenderer({ safe: true }); + return writer.render(reader.parse(text)); +} + +// GitHub-style pipe tables, which CommonMark doesn't have (and the terminology server's +// operations use, for their parameters): a header row, a delimiter row, and the rows +const TABLE_ROW = /^\s*\|.*\|\s*$/; +const TABLE_DELIMITER = /^\s*\|(\s*:?-+:?\s*\|)+\s*$/; + +function tableCells(line) { + const cells = []; + let cell = ''; + const s = line.trim().replace(/^\|/, '').replace(/\|$/, ''); + for (let i = 0; i < s.length; i++) { + if (s[i] === '\\' && s[i + 1] === '\\') { + // an escaped backslash, left for CommonMark (so \\| is a backslash, then the next cell) + cell += '\\\\'; + i++; + } else if (s[i] === '\\' && s[i + 1] === '|') { + cell += '|'; + i++; + } else if (s[i] === '|') { + cells.push(cell.trim()); + cell = ''; + } else { + cell += s[i]; + } + } + cells.push(cell.trim()); + return cells; +} + +function tableCellHtml(text) { + return commonmarkHtml(text).trim().replace(/^

([\s\S]*)<\/p>$/, '$1'); +} + +function tableHtml(header, rows) { + let html = ''; + html += header.map(h => ``).join(''); + html += ''; + for (const row of rows) { + html += '' + header.map((h, i) => ``).join('') + ''; + } + return html + '
${tableCellHtml(h)}
${tableCellHtml(row[i] || '')}
'; +} + +function markdown(text) { + if (!text) { + return ''; + } + const lines = text.split('\n'); + const out = []; + let buf = []; + const flush = () => { + if (buf.length > 0) { + out.push(commonmarkHtml(buf.join('\n'))); + buf = []; + } + }; + for (let i = 0; i < lines.length; i++) { + if (TABLE_ROW.test(lines[i]) && i + 1 < lines.length && TABLE_DELIMITER.test(lines[i + 1])) { + flush(); + const header = tableCells(lines[i]); + const rows = []; + i += 2; + while (i < lines.length && TABLE_ROW.test(lines[i])) { + rows.push(tableCells(lines[i])); + i++; + } + i--; + out.push(tableHtml(header, rows)); + } else { + buf.push(lines[i]); + } + } + flush(); + return out.join(''); +} + +// 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

'; + // an index, when there are enough of them to need one + const all = Object.entries(spec.paths).flatMap(([p, item]) => METHODS.filter(m => item[m]).map(m => [p, m, item[m]])); + if (all.length > 12) { + html += ''; + for (const [p, method, op] of all) { + html += `` + + ``; + } + html += '
${method.toUpperCase()}${escape(BASE_PATH + p)}${escape(op.summary || '')}
'; + } + 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. + * @param {Function} [options.build] - adds to the spec once it's loaded (paths made from + * data, say). The YAML served is then the whole spec, not the file + * @param {string} [options.serverUrl] - the server url, when the module is mounted in more + * than one place (the YAML's servers are replaced) + */ +function createOpenApiDoc(specPath, basePath, options = {}) { + let cachedYaml = null; + let cachedSpec = null; + let cachedHtml = null; + let cachedJson = null; + + function getYaml() { + if (cachedYaml === null) { + cachedYaml = options.build || options.serverUrl + ? YAML.stringify(getSpec(), { lineWidth: 0 }) + : 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(fs.readFileSync(specPath, 'utf8')); + spec.info.version = packageJson.version; + if (options.serverUrl) { + spec.servers = [{ url: options.serverUrl }]; + } + if (options.schemasPath) { + const generated = JSON.parse(fs.readFileSync(options.schemasPath, 'utf8')); + spec.components = spec.components || {}; + spec.components.schemas = { ...generated.schemas, ...(spec.components.schemas || {}) }; + } + if (options.build) { + options.build(spec); + } + cachedSpec = spec; + } + return structuredClone(cachedSpec); + } + + // The spec as JSON text, for serving: made once, rather than a copy cloned and serialised + // for every request + function getJson() { + if (cachedJson === null) { + cachedJson = JSON.stringify(getSpec()); + } + return cachedJson; + } + + // 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, getJson, getYaml, renderHtml, SPEC_PATH: specPath, BASE_PATH: basePath }; +} + +module.exports = { createOpenApiDoc, buildTryItRequest, curlCommand, markdown }; diff --git a/package-lock.json b/package-lock.json index b6d97d28..faf2e2df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "cli-progress": "^3.12.0", "commander": "^14.0.3", "commonmark": "^0.31.2", - "connect-sqlite3": "^0.9.16", + "connect-sqlite3": "^0.9.18", "cors": "^2.8.6", "express": "^5.2.1", "express-rate-limit": "^7.4.1", @@ -42,7 +42,7 @@ "properties-file": "^3.6.4", "re2js": "^2.8.0", "rimraf": "^5.0.10", - "sqlite3": "^5.1.7", + "sqlite3": "^6.0.1", "tar": "^7.5.7", "winston": "^3.19.0", "winston-daily-rotate-file": "^4.7.1", @@ -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", @@ -693,13 +718,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "license": "MIT", - "optional": true - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1444,49 +1462,6 @@ "node": ">= 8" } }, - "node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "license": "MIT", - "optional": true, - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/move-file/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "optional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -1609,16 +1584,6 @@ "text-hex": "1.0.x" } }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2008,11 +1973,14 @@ "license": "ISC" }, "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "license": "ISC", - "optional": true + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, "node_modules/accepts": { "version": "2.0.0", @@ -2082,44 +2050,17 @@ "node": ">= 6.0.0" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "license": "MIT", - "optional": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "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", @@ -2224,28 +2165,6 @@ "node": ">=0.2.6" } }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC", - "optional": true - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2272,13 +2191,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -2565,9 +2484,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2682,73 +2601,6 @@ "node": ">= 0.8" } }, - "node_modules/cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "optional": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2878,16 +2730,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": ">=10" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -2911,16 +2753,6 @@ "dev": true, "license": "MIT" }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3100,16 +2932,6 @@ "node": ">=12.20" } }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "optional": true, - "bin": { - "color-support": "bin.js" - } - }, "node_modules/color/node_modules/color-convert": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", @@ -3180,24 +3002,19 @@ } }, "node_modules/connect-sqlite3": { - "version": "0.9.17", - "resolved": "https://registry.npmjs.org/connect-sqlite3/-/connect-sqlite3-0.9.17.tgz", - "integrity": "sha512-JeO+O6LdLPIk8NOcUwh2DDm748u01F3VFGpx1HPLXGlqrZ5BLmuzcP5VNNsMykXPUvQIOwkf4aWefukdfrIvNQ==", + "version": "0.9.18", + "resolved": "https://registry.npmjs.org/connect-sqlite3/-/connect-sqlite3-0.9.18.tgz", + "integrity": "sha512-ovQ179bGC6ubrEi/Lk77FmZAyaTDqNLCOsJF9FFAjxmIF7NKomWoKP50f3HVffvO3BGgcihE9kDow9TyhDywiA==", "engines": { "node": ">=0.4.x" }, - "peerDependencies": { - "express-session": "^1.0.0", + "optionalDependencies": { "sqlite3": "^5.0.0" + }, + "peerDependencies": { + "express-session": "^1.0.0" } }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC", - "optional": true - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -3312,9 +3129,9 @@ } }, "node_modules/csv-parse": { - "version": "4.16.3", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.16.3.tgz", - "integrity": "sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.3.tgz", + "integrity": "sha512-YFd3QM/yo17vH91L1IOZuvl09zM0zEEtdfTcOCKWcMdM++MNaQSyp09slXyFCLaPXvHstQFx/xC8myiFHSMUvw==", "license": "MIT" }, "node_modules/csv-stringify": { @@ -3432,13 +3249,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT", - "optional": true - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3588,29 +3398,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3642,13 +3429,6 @@ "node": ">=6" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "license": "MIT", - "optional": true - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3839,6 +3619,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 +3646,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", @@ -4008,6 +3812,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -4138,6 +3949,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", @@ -4535,24 +4363,11 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -4579,27 +4394,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -4700,7 +4494,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -4734,7 +4528,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4820,13 +4614,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC", - "optional": true - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -4846,13 +4633,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause", - "optional": true - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4873,21 +4653,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4911,16 +4676,6 @@ "node": ">=10.17.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -5015,35 +4770,18 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "license": "ISC", - "optional": true - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -5091,16 +4829,6 @@ "node": ">=12.0.0" } }, - "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5218,13 +4946,6 @@ "node": ">=8" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "license": "MIT", - "optional": true - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6041,9 +5762,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" }, @@ -6284,64 +6005,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "license": "ISC", - "optional": true, - "dependencies": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/make-fetch-happen/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-fetch-happen/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/make-fetch-happen/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -6522,122 +6185,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "optionalDependencies": { - "encoding": "^0.1.12" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" @@ -6663,9 +6215,9 @@ } }, "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.31.0.tgz", + "integrity": "sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==", "license": "MIT", "engines": { "node": "*" @@ -6820,20 +6372,6 @@ "dev": true, "license": "MIT" }, - "node_modules/natural/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -6892,28 +6430,28 @@ } }, "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "license": "MIT", "optional": true, "dependencies": { "env-paths": "^2.2.0", - "glob": "^7.1.4", + "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": ">= 10.12.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-gyp-build": { @@ -6927,21 +6465,30 @@ "node-gyp-build-test": "build-test.js" } }, - "node_modules/node-gyp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "license": "ISC", "optional": true, "dependencies": { - "glob": "^7.1.3" + "isexe": "^4.0.0" }, "bin": { - "rimraf": "bin.js" + "node-which": "bin/which.js" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-int64": { @@ -7074,19 +6621,19 @@ } }, "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "license": "ISC", "optional": true, "dependencies": { - "abbrev": "1" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": ">=6" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -7112,23 +6659,6 @@ "node": ">=8" } }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "optional": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", @@ -7292,22 +6822,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -7491,7 +7005,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7851,33 +7365,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "license": "ISC", - "optional": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "license": "MIT", - "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -8150,6 +7653,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", @@ -8228,16 +7741,6 @@ "node": ">=8" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8465,13 +7968,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC", - "optional": true - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -8658,47 +8154,6 @@ "node": ">=8" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "license": "MIT", - "optional": true, - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8746,22 +8201,25 @@ "license": "BSD-3-Clause" }, "node_modules/sqlite3": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", - "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", + "integrity": "sha512-X0czUUMG2tmSqJpEQa3tCuZSHKIx8PwM53vLZzKp/o6Rpy25fiVfjdbnZ988M8+O3ZWR1ih0K255VumCb3MAnQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "bindings": "^1.5.0", - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.1", - "tar": "^6.1.11" + "node-addon-api": "^8.0.0", + "prebuild-install": "^7.1.3", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=20.17.0" }, "optionalDependencies": { - "node-gyp": "8.x" + "node-gyp": "12.x" }, "peerDependencies": { - "node-gyp": "8.x" + "node-gyp": "12.x" }, "peerDependenciesMeta": { "node-gyp": { @@ -8769,25 +8227,6 @@ } } }, - "node_modules/sqlite3/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, - "node_modules/ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -9227,7 +8666,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -9244,7 +8683,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -9262,7 +8701,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -9494,6 +8933,16 @@ "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -9501,26 +8950,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "license": "ISC", - "optional": true, - "dependencies": { - "unique-slug": "^2.0.0" - } - }, - "node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "license": "ISC", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -9596,13 +9025,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -9685,16 +9117,6 @@ "node": ">= 8" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "optional": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", diff --git a/package.json b/package.json index 4ab1bdbb..431b3e9b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "cli-progress": "^3.12.0", "commander": "^14.0.3", "commonmark": "^0.31.2", - "connect-sqlite3": "^0.9.16", + "connect-sqlite3": "^0.9.18", "cors": "^2.8.6", "express": "^5.2.1", "express-rate-limit": "^7.4.1", @@ -64,7 +64,7 @@ "properties-file": "^3.6.4", "re2js": "^2.8.0", "rimraf": "^5.0.10", - "sqlite3": "^5.1.7", + "sqlite3": "^6.0.1", "tar": "^7.5.7", "winston": "^3.19.0", "winston-daily-rotate-file": "^4.7.1", @@ -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", @@ -111,7 +112,12 @@ "inquirer": { "lodash": "4.18.1" }, - "brace-expansion": "^5.0.8" + "brace-expansion": "^5.0.8", + "csv-parse": "^7.0.3", + "uuid": "^11.1.1", + "connect-sqlite3": { + "sqlite3": "$sqlite3" + } }, "bin": { "tx-import": "./tx/importers/tx-import.js", 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..d4138fb2 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(/[#|]/g, '@')}%` }); } @@ -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(/[#|]/g, '@')}%`; } 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.type('application/json').send(packagesOpenApi.getJson()); + } 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.type('application/json').send(packagesOpenApi.getJson()); + 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. 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..3c2bf64c 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.type('application/json').send(registryOpenApi.getJson()); + }); + 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.type('application/json').send(registryOpenApi.getJson()); + 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 diff --git a/server.js b/server.js index 98b9c511..eef8bed5 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 += '
  • '; } @@ -402,7 +402,9 @@ async function buildRootPageContent() { if (config.modules.tx.endpoints && config.modules.tx.endpoints.length > 0) { content += '
      '; for (const endpoint of config.modules.tx.endpoints) { - content += `
    • ${endpoint.path} (FHIR v${endpoint.fhirVersion}${endpoint.context ? ', context: ' + endpoint.context : ''})
    • `; + // the OpenAPI description is for the R5 endpoints + const api = String(endpoint.fhirVersion).startsWith('5') ? ` (API)` : ''; + content += `
    • ${endpoint.path} (FHIR v${endpoint.fhirVersion}${endpoint.context ? ', context: ' + endpoint.context : ''})${api}
    • `; } content += '
    '; } 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..61dce444 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.type('application/json').send(testingOpenApi.getJson()))); + 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.type('application/json').send(testingOpenApi.getJson()); + } + 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))); 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', () => { diff --git a/tests/library/openapi-utilities.test.js b/tests/library/openapi-utilities.test.js new file mode 100644 index 00000000..317950d3 --- /dev/null +++ b/tests/library/openapi-utilities.test.js @@ -0,0 +1,53 @@ +// The helpers behind the OpenAPI descriptions: applyOverlay (library/fhir-openapi-schema.js) +// and extractSchema (utilities/extract-schema-json.js). + +const { applyOverlay } = require('../../library/fhir-openapi-schema'); +const { extractSchema } = require('../../utilities/extract-schema-json'); + +describe('applyOverlay', () => { + const base = () => ({ + A: { type: 'object', properties: { x: { type: 'string', description: 'x' } }, required: ['x'] } + }); + + test('merges objects, unions required, replaces the rest, and adds new schemas', () => { + const s = applyOverlay(base(), { + A: { required: ['y'], properties: { x: { minLength: 1 }, y: { type: 'integer' } } }, + B: { type: 'string' } + }); + expect(s.A.required).toEqual(['x', 'y']); + expect(s.A.properties.x).toEqual({ type: 'string', description: 'x', minLength: 1 }); + expect(s.A.properties.y).toEqual({ type: 'integer' }); + expect(s.B).toEqual({ type: 'string' }); + }); + + test('$replace replaces an object instead of merging it', () => { + const s = applyOverlay(base(), { A: { properties: { x: { $replace: true, type: 'boolean' } } } }); + expect(s.A.properties.x).toEqual({ type: 'boolean' }); + }); + + test('refuses keys that would reach a prototype', () => { + expect(() => applyOverlay(base(), JSON.parse('{"A": {"__proto__": {"polluted": true}}}'))).toThrow(/__proto__/); + expect(() => applyOverlay(base(), { A: { properties: { constructor: { prototype: { polluted: true } } } } })).toThrow(/constructor/); + expect(() => applyOverlay(base(), JSON.parse('{"__proto__": {"polluted": true}}'))).toThrow(/__proto__/); + expect({}.polluted).toBeUndefined(); + }); +}); + +describe('extractSchema', () => { + test('takes the one
    , strips markup, decodes entities', () => {
    +    const r = extractSchema('
    {"a": "<b>", "c": "d"}
    '); + expect(r).toEqual({ json: '{"a": "", "c": "d"}\n' }); + }); + + test('strips nested and overlapping markup completely', () => { + const r = extractSchema('
    {"a": 1}ipt>xipt>
    '); + expect(r.json).toBeUndefined(); + expect(r.problem).toMatch(/not valid JSON/); + expect(extractSchema('
    <i>{"a": 1}
    ').json).toBe('{"a": 1}\n'); + }); + + test('reports a page without exactly one
    ', () => {
    +    expect(extractSchema('

    none

    ').problem).toBe('no
     element');
    +    expect(extractSchema('
    {}
    {}
    ').problem).toBe('2
     elements');
    +  });
    +});
    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..5d5337ae --- /dev/null +++ b/tests/server/openapi-doc.test.js @@ -0,0 +1,118 @@ +// The shared OpenAPI page renderer (library/openapi-doc.js): the "try it" forms, and markdown. + +const { buildTryItRequest, curlCommand, markdown } = 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(/