diff --git a/tests/tx/html-valueset-show-all.test.js b/tests/tx/html-valueset-show-all.test.js new file mode 100644 index 00000000..5fddc9da --- /dev/null +++ b/tests/tx/html-valueset-show-all.test.js @@ -0,0 +1,83 @@ +/** + * The HTML page of a value set lists the first 500 codes of each include and links to the + * same page with ?_showAll=true, which lists them all. + */ + +const { TxHtmlRenderer } = require('../../tx/tx-html'); +const { Renderer } = require('../../tx/library/renderer'); + +const big = (n) => ({ + resourceType: 'ValueSet', + id: 'big', + status: 'active', + compose: { + include: [{ + system: 'http://example.org/cs', + concept: Array.from({ length: n }, (_, i) => ({ code: 'c' + i, display: 'Code ' + i })), + }], + }, +}); + +function build() { + const looked = []; + const linkResolver = { + resolveURL: async () => null, + resolveCode: async () => null, + resolveCodes: async (ctx, system, version, codes) => { + looked.push(codes.length); + return codes.map(() => null); + }, + }; + const renderer = new Renderer({ i18n: { formatPhrase: (id) => id, formatPhrasePlural: (id) => id }, langs: null }, linkResolver); + return { looked, html: new TxHtmlRenderer(renderer, null, null, null, '/tx/r4') }; +} + +// The page's own expansion needs a server; without one it reports an error, which is +// not what is being tested. +const get = (url) => { + const query = Object.fromEntries(new URL(url, 'http://localhost').searchParams); + return { method: 'GET', path: '/ValueSet/big', originalUrl: url, query }; +}; + +const rows = (html) => (html.match(/
  • c\d+: /g) || []).length; + +describe('value set page: show all codes', () => { + test('by default, 500 codes and a link to this page with _showAll=true', async () => { + const { looked, html } = build(); + const out = await html.render(big(33341), get('/tx/r4/ValueSet/big?_format=html&lang=en')); + expect(rows(out)).toBe(500); + expect(looked).toEqual([500]); + expect(out).toContain('… and 32,841 more codes. ' + + 'Show all'); + }); + + test('_showAll=true lists every code', async () => { + const { looked, html } = build(); + const out = await html.render(big(33341), get('/tx/r4/ValueSet/big?_format=html&_showAll=true')); + expect(rows(out)).toBe(33341); + expect(looked).toEqual([33341]); + expect(out).not.toContain('more codes'); + }); + + test('in a bundle, the link goes to the value set\'s own page', async () => { + const { html } = build(); + const out = await html.render(big(600), get('/tx/r4/ValueSet?_format=html&_showAll=true'), true); + expect(rows(out)).toBe(500); + expect(out).toContain('Show all'); + }); + + test('a page that came from a POST says how many more, without a link', async () => { + const { html } = build(); + const req = { ...get('/tx/r4/ValueSet/$expand?_format=html'), method: 'POST', path: '/ValueSet/$expand' }; + const out = await html.render(big(600), req); + expect(out).toContain('… and 100 more codes.'); + expect(out).not.toContain('Show all'); + }); + + test('a small value set is listed in full, with no extra line', async () => { + const { html } = build(); + const out = await html.render(big(3), get('/tx/r4/ValueSet/big?_format=html')); + expect(rows(out)).toBe(3); + expect(out).not.toContain('more code'); + }); +}); diff --git a/tests/tx/provider-resolve-code.test.js b/tests/tx/provider-resolve-code.test.js new file mode 100644 index 00000000..528af5a3 --- /dev/null +++ b/tests/tx/provider-resolve-code.test.js @@ -0,0 +1,214 @@ +/** + * resolveCode is asked about every code a page shows, so it must not build the code + * system once per code: for a database-backed code system each build opens a connection + * that stays open until the request ends. It builds one provider per code system and + * version in an operation, and reuses it. + * + * It also has to wait for locate(), which is async, so that what a code resolves to is + * the concept that was found, with its display. + */ + +const { Provider } = require('../../tx/provider'); +const { OperationContext } = require('../../tx/operation-context'); +const { Languages } = require('../../library/languages'); +const { CodeSystemFactoryProvider } = require('../../tx/cs/cs-api'); + +const SYSTEM = 'http://example.org/states'; +const CODES = { AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona' }; + +/** A factory whose providers find CODES, counting how many it builds. */ +function fakeFactory({ iteratable = true, codeLink = () => undefined, id = 'states' } = {}) { + const factory = { + builds: 0, + closed: 0, + id: () => id, + iteratable: () => iteratable, + codeLink, + async build() { + factory.builds++; + return { + async locate(code) { + return CODES[code] ? { context: { code, display: CODES[code] }, message: null } : { context: null, message: 'not found' }; + }, + async display(context) { + return context.display; + }, + async close() { + factory.closed++; + }, + }; + }, + }; + return factory; +} + +function providerWith(factories) { + // Building a real Provider means building a library; resolveCode needs only these. + const provider = Object.create(Provider.prototype); + provider.path = '/tx/r4'; + provider.codeSystemFactories = new Map(Object.entries(factories)); + provider.codeSystems = new Map(); + return provider; +} + +const newContext = () => new OperationContext(new Languages(null)); + +describe('resolveCode', () => { + test('builds the provider once for many codes of one system', async () => { + const factory = fakeFactory(); + const provider = providerWith({ [SYSTEM]: factory }); + const opContext = newContext(); + for (let i = 0; i < 50; i++) { + for (const code of Object.keys(CODES)) { + await provider.resolveCode(opContext, SYSTEM, null, code); + } + } + expect(factory.builds).toBe(1); + // ...and registers it once, so it is closed once when the operation ends. + await opContext.closeProviders(); + expect(factory.closed).toBe(1); + }); + + test('calls that overlap share one build', async () => { + const factory = fakeFactory(); + const provider = providerWith({ [SYSTEM]: factory }); + const opContext = newContext(); + const results = await Promise.all(Object.keys(CODES).map((code) => provider.resolveCode(opContext, SYSTEM, null, code))); + expect(factory.builds).toBe(1); + expect(results.map((r) => r.description)).toEqual(Object.values(CODES)); + }); + + test('builds once per system and version, and again for another operation', async () => { + const v1 = fakeFactory(); + const v2 = fakeFactory(); + const provider = providerWith({ [SYSTEM + '|1']: v1, [SYSTEM + '|2']: v2 }); + const a = newContext(); + await provider.resolveCode(a, SYSTEM, '1', 'AL'); + await provider.resolveCode(a, SYSTEM + '|1', null, 'AK'); + await provider.resolveCode(a, SYSTEM, '2', 'AL'); + expect([v1.builds, v2.builds]).toEqual([1, 1]); + await provider.resolveCode(newContext(), SYSTEM, '1', 'AL'); + expect(v1.builds).toBe(2); + }); + + test("waits for locate(), so a code comes back with the concept's display", async () => { + const provider = providerWith({ [SYSTEM]: fakeFactory() }); + expect(await provider.resolveCode(newContext(), SYSTEM, null, 'AK')) + .toEqual({ link: '/tx/r4/CodeSystem/x-states', description: 'Alaska' }); + }); + + test('a code the code system does not have resolves to nothing', async () => { + const provider = providerWith({ [SYSTEM]: fakeFactory() }); + expect(await provider.resolveCode(newContext(), SYSTEM, null, 'XX')).toBeNull(); + }); + + test("a factory's codeLink is given the concept that was found", async () => { + const seen = []; + const factory = fakeFactory({ + iteratable: false, + codeLink: (c) => { seen.push(c); return 'https://example.org/browse/' + c.code; }, + }); + const provider = providerWith({ [SYSTEM]: factory }); + expect(await provider.resolveCode(newContext(), SYSTEM, null, 'AZ')) + .toEqual({ link: 'https://example.org/browse/AZ', description: 'Arizona' }); + expect(seen).toEqual([{ code: 'AZ', display: 'Arizona' }]); + }); + + test('a build that fails is tried again on the next call', async () => { + const factory = fakeFactory(); + const build = factory.build; + let fail = true; + factory.build = async (...args) => { + if (fail) { + fail = false; + throw new Error('no database'); + } + return build(...args); + }; + const provider = providerWith({ [SYSTEM]: factory }); + const opContext = newContext(); + await expect(provider.resolveCode(opContext, SYSTEM, null, 'AL')).rejects.toThrow('no database'); + expect((await provider.resolveCode(opContext, SYSTEM, null, 'AL')).description).toBe('Alabama'); + }); +}); + +/** A factory whose locate() waits until released, recording how many run at once. */ +function slowFactory() { + const factory = fakeFactory(); + const build = factory.build; + factory.inFlight = 0; + factory.maxInFlight = 0; + factory.locates = 0; + factory.build = async (...args) => { + const csp = await build(...args); + const locate = csp.locate; + csp.locate = async (code) => { + factory.locates++; + factory.inFlight++; + factory.maxInFlight = Math.max(factory.maxInFlight, factory.inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + factory.inFlight--; + return locate(code); + }; + return csp; + }; + return factory; +} + +describe('resolveCodes', () => { + const many = (n) => Array.from({ length: n }, (_, i) => Object.keys(CODES)[i % 3]); + + test('builds the provider once, and locates the codes several at a time but not all at once', async () => { + const factory = slowFactory(); + const provider = providerWith({ [SYSTEM]: factory }); + const results = await provider.resolveCodes(newContext(), SYSTEM, null, many(100)); + expect(factory.builds).toBe(1); + expect(factory.locates).toBe(100); + expect(factory.maxInFlight).toBeGreaterThan(1); + expect(factory.maxInFlight).toBeLessThanOrEqual(8); + // ...and the answers come back in the order of the codes. + expect(results.map((r) => r.description)).toEqual(many(100).map((c) => CODES[c])); + }); + + test('one build per code system across a page, however the codes are asked for', async () => { + const a = slowFactory(); + const b = slowFactory(); + const provider = providerWith({ [SYSTEM + '|a']: a, [SYSTEM + '|b']: b }); + const opContext = newContext(); + await Promise.all([ + provider.resolveCodes(opContext, SYSTEM, 'a', many(30)), + provider.resolveCodes(opContext, SYSTEM, 'b', many(30)), + provider.resolveCodes(opContext, SYSTEM, 'a', many(30)), + provider.resolveCode(opContext, SYSTEM, 'b', 'AL'), + ]); + expect([a.builds, b.builds]).toEqual([1, 1]); + }); + + test('a code it does not have is null, in its place', async () => { + const provider = providerWith({ [SYSTEM]: fakeFactory() }); + const results = await provider.resolveCodes(newContext(), SYSTEM, null, ['AL', 'XX', 'AZ']); + expect(results.map((r) => r && r.description)).toEqual(['Alabama', null, 'Arizona']); + }); + + test('no codes, no build', async () => { + const factory = fakeFactory(); + const provider = providerWith({ [SYSTEM]: factory }); + expect(await provider.resolveCodes(newContext(), SYSTEM, null, [])).toEqual([]); + expect(factory.builds).toBe(0); + }); + + test('a code system that cannot link a code is not built or asked', async () => { + // Like RxNorm or LOINC: not iteratable, and codeLink() is the default, which links nothing. + const factory = Object.assign(Object.create(CodeSystemFactoryProvider.prototype), { + builds: 0, + id: () => 'db', + async build() { factory.builds++; throw new Error('should not be built'); }, + }); + const provider = providerWith({ [SYSTEM]: factory }); + // A CodeSystem resource for the same system still gives its codes a link. + provider.codeSystems.set(SYSTEM, { id: 'states-cs', codeMap: new Map([['AL', { display: 'Alabama' }]]) }); + const results = await provider.resolveCodes(newContext(), SYSTEM, null, ['AL', 'AK']); + expect(factory.builds).toBe(0); + expect(results).toEqual([{ link: '/tx/r4/CodeSystem/states-cs#AL', description: 'Alabama' }, null]); + }); +}); diff --git a/tests/tx/renderer-include-codes.test.js b/tests/tx/renderer-include-codes.test.js new file mode 100644 index 00000000..eef04f9c --- /dev/null +++ b/tests/tx/renderer-include-codes.test.js @@ -0,0 +1,134 @@ +/** + * A value set that lists its codes is rendered one row per code. VSAC's Medication Brand + * Name lists 33,341 codes in one include, so by default an include shows its first + * MAX_RENDERED_INCLUDE_CODES codes, then a line saying how many more there are, with a + * link to the page that shows them all. + * + * The codes that are shown are looked up for a link and a description in one request to + * the link resolver per include, not one request per code. + */ + +const { Renderer, MAX_RENDERED_INCLUDE_CODES } = require('../../tx/library/renderer'); +const { div } = require('../../library/html'); + +const SYSTEM = 'http://example.org/cs'; + +/** A link resolver that counts its calls, with or without resolveCodes. */ +function setup({ batch = true } = {}) { + const single = []; + const batches = []; + const resolve = (code) => ({ link: 'LINK:' + code, description: 'Described ' + code }); + const linkResolver = { + resolveURL: async () => null, + resolveCode: async (ctx, system, version, code) => { + single.push(code); + return resolve(code); + }, + }; + if (batch) { + linkResolver.resolveCodes = async (ctx, system, version, codes) => { + batches.push({ system, version, codes }); + return codes.map(resolve); + }; + } + const opContext = { i18n: { formatPhrase: (id) => id }, langs: null }; + return { single, batches, renderer: new Renderer(opContext, linkResolver) }; +} + +const include = (n, withDisplay = true) => ({ + system: SYSTEM, + concept: Array.from({ length: n }, (_, i) => (withDisplay ? { code: 'c' + i, display: 'Code ' + i } : { code: 'c' + i })), +}); + +async function render(renderer, inc) { + const x = div(); + await renderer.renderInclude(x, inc); + return x.toString(); +} + +const rows = (html) => (html.match(/
  • /g) || []).length; + +describe('renderInclude: how many codes are listed', () => { + test('the default is 500 codes', () => { + expect(MAX_RENDERED_INCLUDE_CODES).toBe(500); + expect(setup().renderer.includeCodeLimit).toBe(500); + }); + + test('a longer include lists the first 500, then how many more, with a link to them all', async () => { + const { renderer } = setup(); + renderer.showAllCodesHref = '/tx/r4/ValueSet/big?_format=html&_showAll=true'; + const html = await render(renderer, include(33341)); + expect(rows(html)).toBe(501); + expect(html).toContain('c499: Code 499'); + expect(html).not.toContain('c500'); + expect(html).toContain('… and 32,841 more codes. Show all'); + }); + + test('without a link to give, it still says how many more there are', async () => { + const { renderer } = setup(); + const html = await render(renderer, include(501)); + expect(html).toContain('… and 1 more code.'); + expect(html).not.toContain('Show all'); + }); + + test('with no limit, every code is listed and looked up', async () => { + const { batches, renderer } = setup(); + renderer.includeCodeLimit = null; + const html = await render(renderer, include(1200)); + expect(rows(html)).toBe(1200); + expect(html).toContain('c1199: Code 1199'); + expect(html).not.toContain('more code'); + expect(batches[0].codes).toHaveLength(1200); + }); + + test('at 500 codes, nothing is cut', async () => { + const { renderer } = setup(); + const html = await render(renderer, include(500)); + expect(rows(html)).toBe(500); + expect(html).not.toContain('more code'); + }); + + test('every code is still checked, including the ones not listed', async () => { + const { renderer } = setup(); + const inc = include(600); + inc.concept[550].code = ''; + await expect(render(renderer, inc)).rejects.toThrow(); + }); +}); + +describe('renderInclude: look-ups', () => { + test('the listed codes are looked up in one request, and only those', async () => { + const { single, batches, renderer } = setup(); + await render(renderer, { ...include(2000), version: '2' }); + expect(single).toHaveLength(0); + expect(batches).toHaveLength(1); + expect(batches[0].system).toBe(SYSTEM); + expect(batches[0].version).toBe('2'); + expect(batches[0].codes).toEqual(Array.from({ length: 500 }, (_, i) => 'c' + i)); + }); + + test('a resolver without resolveCodes is asked one code at a time', async () => { + const { single, renderer } = setup({ batch: false }); + const html = await render(renderer, include(3)); + expect(single).toEqual(['c0', 'c1', 'c2']); + expect(html).toContain('c0: Code 0'); + }); + + test('a small include renders as it did before', async () => { + const { renderer } = setup(); + renderer.linkResolver.resolveCodes = async (ctx, system, version, codes) => + codes.map((code) => (code === 'c1' ? { link: 'LINK:c1', description: 'Described c1' } : null)); + const html = await render(renderer, { + system: SYSTEM, + concept: [{ code: 'c0', display: 'Code 0' }, { code: 'c1' }, { code: 'c2', display: 'Code 2' }], + }); + // The same as main renders it. + expect(html).toBe('
    \n' + + 'VALUE_SET_THESE_CODES_DEF http://example.org/cs: \n' + + '
    \n'); + }); +}); diff --git a/tx/library/renderer.js b/tx/library/renderer.js index 075ba373..b22d9af2 100644 --- a/tx/library/renderer.js +++ b/tx/library/renderer.js @@ -25,12 +25,22 @@ const VALID_CODESYSTEM_CONTENT = new Set([ ]); const VALID_PUBLICATION_STATUS = new Set(['draft', 'active', 'retired', 'unknown']); +/** + * How many of the codes a ValueSet.compose include (or exclude) lists are rendered by + * default. Past this, the list ends with a line saying how many more there are and, when + * the renderer has been given showAllCodesHref, a link to the page with all of them. + * VSAC's Medication Brand Name lists 33,341 codes in one include. + */ +const MAX_RENDERED_INCLUDE_CODES = 500; + /** * @typedef {Object} TerminologyLinkResolver * @property {function(OperationContext, string, string=): {description: string, link: string}|null} resolveURL * Given a URL and optional version, returns description and link, or null if not found * @property {function(OperationContext, string, string, string=): {display: string, link: string}|null} resolveCode * Given a URL, code, and optional version, returns display and link, or null if not found + * @property {function(OperationContext, string, string, string[]): Array<{description: string, link: string}|null>} [resolveCodes] + * resolveCode for many codes of one code system, in their order; used where present */ class Renderer { @@ -38,6 +48,10 @@ class Renderer { constructor(opContext, linkResolver = null) { this.opContext = opContext; this.linkResolver = linkResolver; + /** How many codes of an include to render; null renders them all. */ + this.includeCodeLimit = MAX_RENDERED_INCLUDE_CODES; + /** Where "Show all" goes when an include is cut short; null for no link. */ + this.showAllCodesHref = null; } displayCoded(...args) { @@ -726,8 +740,14 @@ class Renderer { const ul = li.ul(); for (let c of inc.concept) { this._requireValidCode(c.code, 'ValueSet.compose.include.concept.code'); + } + const limit = this.includeCodeLimit; + const shown = limit != null && inc.concept.length > limit ? inc.concept.slice(0, limit) : inc.concept; + const links = await this.resolveIncludeCodes(inc, shown); + for (let i = 0; i < shown.length; i++) { + const c = shown[i]; + const link = links[i]; const li = ul.li(); - const link = this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, inc.system, inc.version, c.code) : null; if (link) { li.ah(link.link).tx(c.code); } else { @@ -739,6 +759,15 @@ class Renderer { li.span("opaque: 0.5").tx(": "+link.description); } } + if (shown.length < inc.concept.length) { + const more = inc.concept.length - shown.length; + const li = ul.li(); + li.i().tx(`\u2026 and ${more.toLocaleString('en-US')} more code${more === 1 ? '' : 's'}.`); + if (this.showAllCodesHref) { + li.tx(" "); + li.ah(this.showAllCodesHref).tx("Show all"); + } + } } else { li.tx(this.translate('VALUE_SET_CODES_FROM')+" "); await this.renderLink(li,inc.system+(inc.version ? "|"+inc.version : "")); @@ -777,6 +806,29 @@ class Renderer { } } + /** + * The link and description for each of the codes of an include that are shown, in one + * request to the link resolver for the include's code system where it can take one. + * + * @param {Object} inc the include + * @param {Object[]} concepts the include's concepts being rendered + * @returns {Promise>} + */ + async resolveIncludeCodes(inc, concepts) { + if (!this.linkResolver || concepts.length === 0) { + return concepts.map(() => null); + } + const codes = concepts.map((c) => c.code); + if (this.linkResolver.resolveCodes) { + return await this.linkResolver.resolveCodes(this.opContext, inc.system, inc.version, codes); + } + const links = []; + for (const code of codes) { + links.push(await this.linkResolver.resolveCode(this.opContext, inc.system, inc.version, code)); + } + return links; + } + async generateProperties(x, cs) { if (!cs.property || cs.property.length === 0) { return false; @@ -2769,4 +2821,4 @@ class Renderer { } -module.exports = { Renderer, VALID_FILTER_OPS }; +module.exports = { Renderer, VALID_FILTER_OPS, MAX_RENDERED_INCLUDE_CODES }; diff --git a/tx/provider.js b/tx/provider.js index d5ebef2e..83c2037f 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -12,6 +12,38 @@ const {PackageValueSetProvider} = require("./vs/vs-package"); const ValueSet = require("./library/valueset"); const {PackageConceptMapProvider} = require("./cm/cm-package"); const { applyIdPrefix, CORE_PREFIX } = require("./library/resource-ids"); +const { CodeSystemFactoryProvider } = require("./cs/cs-api"); + +/** + * The code system providers resolveCode() has built, per operation: opContext -> + * Map(factory -> Promise of the provider). A page asks resolveCode about every code it + * shows, and for a database-backed code system each build opens a connection that stays + * open until the request ends, so one is built per code system and version, and reused. + * Keyed weakly, so the entry goes when the operation does. + */ +const resolveCodeProviders = new WeakMap(); + +/** + * How many codes resolveCodes() locates at once. Enough to keep a database-backed code + * system busy without queueing a whole page's worth of queries at the same time. + */ +const RESOLVE_CODES_CONCURRENCY = 8; + +/** + * fn over items, at most `limit` at a time, with the results in the order of the items. + */ +async function mapConcurrently(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i], i); + } + }; + await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker)); + return results; +} /** * This class holds what information is in context @@ -453,10 +485,31 @@ class Provider { } async resolveCode(opContext, system, version, code) { + validateParameter(code, "code", String); + return (await this.resolveCodes(opContext, system, version, [code]))[0]; + } + + /** + * resolveCode() for many codes of one code system: where each code lives (a link) and + * its display, or null, in the order of the codes. + * + * The code system is found and its provider built once for all of them, and the codes + * are located RESOLVE_CODES_CONCURRENCY at a time rather than one after another. + * + * @param {OperationContext} opContext + * @param {string} system + * @param {string|null} version + * @param {string[]} codes + * @returns {Promise>} + */ + async resolveCodes(opContext, system, version, codes) { validateParameter(opContext, "opContext", OperationContext); validateParameter(system, "system", String); validateOptionalParameter(version, "version", String); - validateParameter(code, "code", String); + validateArrayParameter(codes, "codes", String); + if (codes.length === 0) { + return []; + } if (system.includes("|")) { const url = system.substring(0, system.indexOf("|")); @@ -476,41 +529,80 @@ class Provider { if (factory == null && vurlMM) { factory = this.codeSystemFactories.get(vurlMM); } - if (factory != null) { - const csp = await factory.build(opContext, []); - opContext.registerProvider(csp); - const c = csp ? csp.locate(code) : null; - if (c) { - if (factory.iteratable()) { - return { - link: this.path + "/CodeSystem/x-" + factory.id(), - description: csp.display(c) - } - } else { - const link = factory.codeLink(c); - if (link) { - return { - link: link, - description: csp.display(c) - } - } - } - } - } + // A code system from a factory gives a code a link only if the factory is iteratable + // (US states) or has a codeLink() of its own. For the rest (RxNorm, LOINC, SNOMED CT, + // ...) what locate() finds would be thrown away, so they are not built or asked. + // That matters: RxNorm looks for a code it does not have in its archive table, which + // takes 36 ms a code, and 4,529 of Medication Brand Name's codes are not current. + const linksCodes = factory != null + && (factory.iteratable() || factory.codeLink !== CodeSystemFactoryProvider.prototype.codeLink); + const csp = linksCodes ? await this.resolveCodeProvider(opContext, factory) : null; let cs = this.codeSystems.get(vurl); if (cs == null && vurlMM) { cs = this.codeSystems.get(vurlMM); } - if (cs != null) { - const c = cs.codeMap.get(code); - if (c) { - return { - link: this.path + "/CodeSystem/" + cs.id + '#' + code, - description: c.display - }; + + return mapConcurrently(codes, RESOLVE_CODES_CONCURRENCY, async (code) => { + if (csp) { + const located = await csp.locate(code); + const c = located ? located.context : null; + if (c) { + if (factory.iteratable()) { + return { + link: this.path + "/CodeSystem/x-" + factory.id(), + description: await csp.display(c) + } + } else { + const link = factory.codeLink(c); + if (link) { + return { + link: link, + description: await csp.display(c) + } + } + } + } } - } - return null; + if (cs != null) { + const c = cs.codeMap.get(code); + if (c) { + return { + link: this.path + "/CodeSystem/" + cs.id + '#' + code, + description: c.display + }; + } + } + return null; + }); + } + + /** + * The provider resolveCode() uses for a code system factory in this operation: built + * the first time it is asked for, registered for closing when the operation ends, and + * reused after that. The promise is kept, so calls that overlap share one build. + * + * @param {OperationContext} opContext + * @param {CodeSystemFactoryProvider} factory + * @returns {Promise} + */ + resolveCodeProvider(opContext, factory) { + let built = resolveCodeProviders.get(opContext); + if (!built) { + built = new Map(); + resolveCodeProviders.set(opContext, built); + } + let csp = built.get(factory); + if (!csp) { + csp = (async () => { + const p = await factory.build(opContext, []); + opContext.registerProvider(p); + return p; + })(); + // A failed build is not kept, so the next call tries again. + csp.catch(() => built.delete(factory)); + built.set(factory, csp); + } + return csp; } /** diff --git a/tx/tx-html.js b/tx/tx-html.js index 1c614fc1..91681423 100644 --- a/tx/tx-html.js +++ b/tx/tx-html.js @@ -21,7 +21,7 @@ const {ParametersXML} = require("./xml/parameters-xml"); const {OperationOutcomeXML} = require("./xml/operationoutcome-xml"); const {debugLog} = require("./operation-context"); const {InvalidError} = require("./library/errors"); -const {VALID_FILTER_OPS} = require("./library/renderer"); +const {VALID_FILTER_OPS, MAX_RENDERED_INCLUDE_CODES} = require("./library/renderer"); const txOpenApi = require("./openapi"); const txHtmlLog = Logger.getInstance().child({ module: 'tx-html' }); @@ -61,6 +61,12 @@ const ELEMENT_OPTIONS = ['id', 'url', 'version', 'name', 'title', 'status', 'dat // something, not about terminology, so it does not belong in the FHIR tools namespace. const SNOMED_URI = 'http://snomed.info/sct'; +// A value set page lists the first MAX_RENDERED_INCLUDE_CODES codes of each include; +// ?_showAll=true lists them all. Underscored, as the other parameters that control how a +// result is presented are (_format, _summary, _elements), which also keeps it apart from +// the $expand parameters: the page's query string is passed to the expansion it shows. +const SHOW_ALL_PARAM = '_showAll'; + const TRANSIENT_VALUESET = 'http://healthintersections.com.au/fhirsmith/StructureDefinition/valueset-transient'; const SEVERITY_ALERT_CLASS = { @@ -592,6 +598,7 @@ class TxHtmlRenderer { case 'CodeSystem': return await this.renderCodeSystem(json, inBundle, _fmt, op, req.sourcePackage); case 'ValueSet': { + this.limitIncludeCodes(json, req, inBundle); let exp = undefined; if (!inBundle && !op && (!_fmt || _fmt == 'html')) { try { @@ -626,6 +633,35 @@ class TxHtmlRenderer { } } + /** + * How many codes of each include the value set's page lists, and where its "Show all" + * link goes: this page with ?_showAll=true, or, for a value set shown in a bundle, the + * value set's own page. A page that came from a POST has no URL to ask again with, so + * it says how many codes there are but has no link. + * + * @param {Object} json the value set + * @param {express.Request|null} req + * @param {boolean} inBundle + */ + limitIncludeCodes(json, req, inBundle) { + if (!inBundle && req?.query?.[SHOW_ALL_PARAM] === 'true') { + this.renderer.includeCodeLimit = null; + this.renderer.showAllCodesHref = null; + return; + } + this.renderer.includeCodeLimit = MAX_RENDERED_INCLUDE_CODES; + if (inBundle) { + this.renderer.showAllCodesHref = json.id + ? `${this.path}/ValueSet/${encodeURIComponent(json.id)}?_format=html&${SHOW_ALL_PARAM}=true` : null; + } else if (req?.method === 'GET' && req.originalUrl) { + const url = new URL(req.originalUrl, 'http://localhost'); + url.searchParams.set(SHOW_ALL_PARAM, 'true'); + this.renderer.showAllCodesHref = url.pathname + url.search; + } else { + this.renderer.showAllCodesHref = null; + } + } + /** * Render Parameters resource */