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'
+ + ' - c0: Code 0
\n'
+ + ' - c1: Described c1
\n'
+ + ' - c2: Code 2
\n'
+ + '
\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
*/