diff --git a/tests/ocl/ocl-cm-provider.test.js b/tests/ocl/ocl-cm-provider.test.js index a1e2fc4a..08081c8f 100644 --- a/tests/ocl/ocl-cm-provider.test.js +++ b/tests/ocl/ocl-cm-provider.test.js @@ -214,6 +214,13 @@ describe('OCLConceptMapProvider', () => { ]; const getMock = jest.fn().mockImplementation((url) => { + // {source}/mappings/ — one request returns every mapping the source owns. + // Verified against the live OCL instance: this returns the same set as + // walking concepts and asking each one, without the per-concept fan-out. + // Must precede the '/sources/' branch below, which would otherwise swallow it. + if (url.endsWith('/mappings/') && !url.includes('/concepts/')) { + return Promise.resolve({ data: mappings }); + } // source search — resolve canonical for SourceA if (url.includes('/sources/') && !url.includes('/concepts/')) { return Promise.resolve({ @@ -411,3 +418,114 @@ describe('OCLConceptMapProvider', () => { }); }); }); + +// --------------------------------------------------------------- +// $resolveReference integration (#2261) +// --------------------------------------------------------------- +describe('OCLConceptMapProvider $resolveReference integration', () => { + const { OclReferenceResolver } = require('../../tx/ocl/resolve/reference-resolver'); + + const SEARCH_ENDPOINT = '/orgs/TestOrg/sources/'; + + function makeProvider({ token = 'Token abc', post } = {}) { + const provider = new OCLConceptMapProvider({ org: 'TestOrg', token }); + const httpClient = { + get: jest.fn(async () => ({ data: [] })), + post: post || jest.fn(async () => ({ data: [] })) + }; + // The provider captures httpClient at construction, so rebuild the resolver + // on the mock the same way the other tests swap httpClient. + provider.httpClient = httpClient; + provider.referenceResolver = new OclReferenceResolver({ + httpClient, + org: 'TestOrg', + token, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } + }); + return { provider, httpClient }; + } + + function resolveReferenceReply(url) { + return jest.fn(async () => ({ + data: [ + { + reference_type: 'canonical', + resolved: true, + request: null, + resolution_url: 'http://x.org/cs', + url_registry_entry: null, + result: { type: 'Source Version', short_code: 'S', url } + } + ] + })); + } + + function getPaths(httpClient) { + return httpClient.get.mock.calls.map(call => call[0]); + } + + const searchParams = [{ name: 'source-system', value: 'http://x.org/cs' }]; + + it('uses the resolved repo and skips the heuristic source search', async () => { + const { provider, httpClient } = makeProvider({ + post: resolveReferenceReply('/users/joe/sources/S/HEAD/') + }); + + await provider.searchConceptMaps(searchParams); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + // The authoritative answer makes the q= text search unnecessary. + expect(getPaths(httpClient)).not.toContain(SEARCH_ENDPOINT); + // ...and a user-owned repo survives, which the old /orgs/-only filter dropped. + expect(getPaths(httpClient)).toContain('/users/joe/sources/S/HEAD/mappings/'); + }); + + // Regression: searchConceptMaps used to lower-case every param value. The old text + // search tolerated it (#norm lower-cases anyway), but $resolveReference matches the + // canonical exactly, so a lower-cased URL never resolved. + it('sends the canonical to $resolveReference with its original casing', async () => { + const { provider, httpClient } = makeProvider({ + post: resolveReferenceReply('/orgs/Mangara/sources/S/HEAD/') + }); + + await provider.searchConceptMaps([ + { name: 'source-system', value: 'https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara' } + ]); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + expect(httpClient.post.mock.calls[0][1]).toEqual([ + 'https://mangara.hsl.org.br/fhir/CodeSystem/AlcoolSPA_uso_Mangara' + ]); + }); + + it('falls back to the source search when no token is configured', async () => { + const { provider, httpClient } = makeProvider({ token: null }); + + await provider.searchConceptMaps(searchParams); + + expect(httpClient.post).not.toHaveBeenCalled(); + expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT); + }); + + it('falls back to the source search when OCL cannot resolve the canonical', async () => { + const post = jest.fn(async () => ({ + data: [{ reference_type: 'canonical', resolved: false, result: null }] + })); + const { provider, httpClient } = makeProvider({ post }); + + await provider.searchConceptMaps(searchParams); + + expect(post).toHaveBeenCalledTimes(1); + expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT); + }); + + it('falls back to the source search when $resolveReference is unavailable', async () => { + const error = new Error('Request failed with status code 404'); + error.response = { status: 404 }; + const { provider, httpClient } = makeProvider({ post: jest.fn().mockRejectedValue(error) }); + + await provider.searchConceptMaps(searchParams); + + expect(getPaths(httpClient)).toContain(SEARCH_ENDPOINT); + }); +}); diff --git a/tests/ocl/ocl-reference-resolver.test.js b/tests/ocl/ocl-reference-resolver.test.js new file mode 100644 index 00000000..5727d455 --- /dev/null +++ b/tests/ocl/ocl-reference-resolver.test.js @@ -0,0 +1,652 @@ +const { + OclReferenceResolver, + normalizeNamespace, + deriveNamespace, + normalizeReference, + isOclRepoPath, + GLOBAL_NAMESPACE, + RESOLVE_PATH +} = require('../../tx/ocl/resolve/reference-resolver'); + +function silentLogger() { + return { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; +} + +// One entry from OCL's $resolveReference response. +// +// Shaped from a real response captured against oclapi2.ips.hsl.org.br, NOT from the +// docs -- the documented example shows only {type, short_code, url}, but the live +// payload carries canonical_url, owner_type, version and checksums, and reports +// type "Source" rather than "Source Version". +function oclEntry({ + resolved = true, + url = '/orgs/MS/sources/BRTabelaSUS/', + canonicalUrl = 'https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS', + owner = 'MS', + ownerType = 'Organization', + registryEntry = null, + referenceType = 'relative', + resolutionUrl = null, + request = null +} = {}) { + return { + reference_type: referenceType, + timestamp: '2026-07-15T17:25:44.682776', + resolved, + request, + resolution_url: resolutionUrl, + url_registry_entry: registryEntry, + result: resolved + ? { + short_code: url.split('/').filter(Boolean).pop(), + name: url.split('/').filter(Boolean).pop(), + url, + owner, + owner_type: ownerType, + owner_url: `/orgs/${owner}/`, + version: 'HEAD', + created_at: '2025-10-24T12:41:29.742565Z', + id: url.split('/').filter(Boolean).pop(), + source_type: 'Dictionary', + updated_at: '2026-06-22T15:25:14.276339Z', + canonical_url: canonicalUrl, + type: 'Source', + checksums: { standard: '282d3c8ce440b8a03698196967042a08', smart: '90599db3f6da397c1af26baaf9467eb1' } + } + : null + }; +} + +// Echoes one result per submitted ref, tagging the url so ordering is assertable. +function echoClient() { + return { + post: jest.fn(async (path, body) => ({ + data: body.map(ref => { + const url = typeof ref === 'string' ? ref : ref.url; + return oclEntry({ url: `/orgs/X/sources/${url}/HEAD/`, request: ref }); + }) + })) + }; +} + +function makeResolver({ httpClient, logger, token = 'Token abc', ...rest } = {}) { + return new OclReferenceResolver({ + httpClient: httpClient || echoClient(), + logger: logger || silentLogger(), + token, + ...rest + }); +} + +function httpError(status, data) { + const error = new Error(`Request failed with status code ${status}`); + error.response = { status, data }; + return error; +} + +describe('normalizeNamespace', () => { + it.each([ + ['MyOrg', '/orgs/MyOrg/'], + ['/orgs/MyOrg', '/orgs/MyOrg/'], + ['orgs/MyOrg/', '/orgs/MyOrg/'], + ['/orgs/MyOrg/', '/orgs/MyOrg/'], + [' /orgs/MyOrg/ ', '/orgs/MyOrg/'], + ['/users/joe/', '/users/joe/'], + ['users/joe', '/users/joe/'] + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeNamespace(input)).toBe(expected); + }); + + it.each([['/'], [''], [' '], [null], [undefined]])( + 'treats %s as the global namespace', + input => { + expect(normalizeNamespace(input)).toBe(GLOBAL_NAMESPACE); + } + ); + + // A malformed namespace must throw rather than silently degrade to global — + // that would look fine while resolving against the wrong context. + it.each([['/orgs/'], ['/users/'], ['/foo/bar/'], ['//'], ['a/b/c'], ['/orgs/a/b/']])( + 'rejects %s', + input => { + expect(() => normalizeNamespace(input)).toThrow(/Invalid OCL namespace/); + } + ); +}); + +describe('isOclRepoPath', () => { + it.each([ + ['/orgs/CIEL/sources/CIEL/'], + ['/orgs/CIEL/sources/CIEL/HEAD/'], + ['/orgs/CIEL/collections/C/'], + // The whole point: user-owned repos are real and must not be filtered out. + ['/users/joe/sources/S/'], + [' /users/joe/sources/S/ '] + ])('accepts %s', input => { + expect(isOclRepoPath(input)).toBe(true); + }); + + it.each([ + ['http://loinc.org'], + ['/sources/S/'], + ['/orgs/'], + ['/orgs/CIEL'], + ['/groups/x/sources/S/'], + [''], + [null], + [undefined] + ])('rejects %p', input => { + expect(isOclRepoPath(input)).toBe(false); + }); +}); + +describe('deriveNamespace', () => { + it('prefers an explicit namespace over org', () => { + expect(deriveNamespace({ namespace: '/users/joe/', org: 'CIEL' })).toBe('/users/joe/'); + }); + + it('derives /orgs/{org}/ from org', () => { + expect(deriveNamespace({ org: 'CIEL' })).toBe('/orgs/CIEL/'); + }); + + it.each([[{}], [{ org: '' }], [{ namespace: ' ', org: null }], [undefined]])( + 'falls back to the global namespace for %s', + input => { + expect(deriveNamespace(input)).toBe(GLOBAL_NAMESPACE); + } + ); +}); + +describe('normalizeReference', () => { + it('accepts a relative path string and sends it as a string', () => { + const ref = normalizeReference('/orgs/CIEL/sources/CIEL/'); + expect(ref.url).toBe('/orgs/CIEL/sources/CIEL/'); + expect(ref.body).toBe('/orgs/CIEL/sources/CIEL/'); + expect(ref.namespace).toBeNull(); + }); + + it('keeps the expanded object fields', () => { + const ref = normalizeReference({ + url: 'http://hl7.org/fhir/CodeSystem/x', + version: '0.8', + code: '1948', + resourceType: 'Mapping' + }); + expect(ref.body).toEqual({ + url: 'http://hl7.org/fhir/CodeSystem/x', + version: '0.8', + code: '1948', + resourceType: 'Mapping' + }); + }); + + it('extracts namespace for grouping but never puts it in the body', () => { + const ref = normalizeReference({ url: '/orgs/A/sources/S/', namespace: '/orgs/A/' }); + expect(ref.namespace).toBe('/orgs/A/'); + expect(ref.body).not.toHaveProperty('namespace'); + }); + + it('drops null and undefined fields', () => { + const ref = normalizeReference({ url: 'x', version: null, code: undefined }); + expect(ref.body).toEqual({ url: 'x' }); + }); + + it.each([[''], [' ']])('rejects the empty string %p', input => { + expect(() => normalizeReference(input)).toThrow(/cannot be empty/); + }); + + it.each([[{}], [{ url: '' }], [{ url: null }]])('rejects object %p without a url', input => { + expect(() => normalizeReference(input)).toThrow(/requires a url/); + }); + + it.each([[null], [undefined], [123], [[]]])('rejects %p', input => { + expect(() => normalizeReference(input)).toThrow(/Invalid OCL reference/); + }); +}); + +describe('OclReferenceResolver construction', () => { + it('requires an http client', () => { + expect(() => new OclReferenceResolver({ token: 'Token abc' })).toThrow(/requires an http client/); + }); + + it('derives its namespace from org', () => { + expect(makeResolver({ org: 'CIEL' }).namespace).toBe('/orgs/CIEL/'); + }); + + it('defaults to the global namespace when no org is configured', () => { + expect(makeResolver().namespace).toBe(GLOBAL_NAMESPACE); + }); + + it('throws on a malformed configured namespace', () => { + expect(() => makeResolver({ namespace: '/foo/bar/' })).toThrow(/Invalid OCL namespace/); + }); + + // $resolveReference is auth-gated on every instance probed, while the /orgs/ + // enumeration it replaces is public — so a tokenless call is a guaranteed 401. + it('stays disabled without a token and issues no request', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient, token: null }); + + expect(resolver.isEnabled()).toBe(false); + expect(resolver.disabledReason).toBe('no token configured'); + await expect(resolver.resolveReferences(['/orgs/A/sources/S/'])).resolves.toBeNull(); + expect(httpClient.post).not.toHaveBeenCalled(); + }); + + it('is enabled with a token', () => { + expect(makeResolver().isEnabled()).toBe(true); + }); +}); + +describe('OclReferenceResolver resolution', () => { + it('resolves a single relative reference', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: [oclEntry({ url: '/orgs/CIEL/sources/CIEL/HEAD/' })] })) + }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/CIEL/sources/CIEL/'); + + expect(result.resolved).toBe(true); + expect(result.repoUrl).toBe('/orgs/CIEL/sources/CIEL/HEAD/'); + expect(httpClient.post).toHaveBeenCalledWith( + RESOLVE_PATH, + ['/orgs/CIEL/sources/CIEL/'], + { params: { namespace: GLOBAL_NAMESPACE } } + ); + }); + + it('resolves an expanded object reference', async () => { + const httpClient = { + post: jest.fn(async () => ({ + data: [oclEntry({ url: '/orgs/CIEL/sources/CIEL/v2021-03-12/', referenceType: 'canonical' })] + })) + }; + const resolver = makeResolver({ httpClient, org: 'CIEL' }); + + const result = await resolver.resolve({ + url: 'http://hl7.org/fhir/CodeSystem/my-codesystem', + version: '0.8', + code: '1948' + }); + + expect(result.resolved).toBe(true); + expect(result.referenceType).toBe('canonical'); + expect(httpClient.post).toHaveBeenCalledWith( + RESOLVE_PATH, + [{ url: 'http://hl7.org/fhir/CodeSystem/my-codesystem', version: '0.8', code: '1948' }], + { params: { namespace: '/orgs/CIEL/' } } + ); + }); + + it('passes a piped canonical through untouched', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + await resolver.resolve('http://terminology.hl7.org/CodeSystem/x|0.1.0'); + + expect(httpClient.post).toHaveBeenCalledWith( + RESOLVE_PATH, + ['http://terminology.hl7.org/CodeSystem/x|0.1.0'], + expect.anything() + ); + }); + + it('resolves a user-owned repo (regression: /orgs/-only filters dropped these)', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: [oclEntry({ url: '/users/joe/sources/S/HEAD/' })] })) + }; + const resolver = makeResolver({ httpClient, namespace: '/users/joe/' }); + + const result = await resolver.resolve('/users/joe/sources/S/'); + + expect(result.repoUrl).toBe('/users/joe/sources/S/HEAD/'); + }); + + // Verbatim response captured from oclapi2.ips.hsl.org.br for + // POST /$resolveReference/ ["/orgs/MS/sources/BRTabelaSUS/concepts/1948/"]. + // Guards against the doc's example, which omits most of these fields. + it('handles a real relative-reference response, concept and all', async () => { + const live = [{ + reference_type: 'relative', + resolved: true, + timestamp: '2026-07-15T17:25:44.682776', + request: '/orgs/MS/sources/BRTabelaSUS/concepts/1948/', + resolution_url: '/orgs/MS/sources/BRTabelaSUS/', + url_registry_entry: null, + result: { + short_code: 'BRTabelaSUS', + name: 'BRTabelaSUS', + url: '/orgs/MS/sources/BRTabelaSUS/', + owner: 'MS', + owner_type: 'Organization', + owner_url: '/orgs/MS/', + version: 'HEAD', + created_at: '2025-10-24T12:41:29.742565Z', + id: 'BRTabelaSUS', + source_type: 'Dictionary', + updated_at: '2026-06-22T15:25:14.276339Z', + canonical_url: 'https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS', + type: 'Source', + checksums: { standard: '282d3c8ce440b8a03698196967042a08', smart: '90599db3f6da397c1af26baaf9467eb1' } + } + }]; + const httpClient = { post: jest.fn(async () => ({ data: live })) }; + const resolver = makeResolver({ httpClient }); + + const r = await resolver.resolve('/orgs/MS/sources/BRTabelaSUS/concepts/1948/'); + + expect(r.resolved).toBe(true); + expect(r.repoUrl).toBe('/orgs/MS/sources/BRTabelaSUS/'); + expect(r.referenceType).toBe('relative'); + // OCL strips the concept: the reference points at a concept, the repo is the source. + expect(r.resolutionUrl).toBe('/orgs/MS/sources/BRTabelaSUS/'); + expect(r.canonical).toBe('https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS'); + expect(r.ownerType).toBe('Organization'); + expect(r.registryEntry).toBeNull(); + // The raw result stays available for anything we don't surface (checksums, version...). + expect(r.result.checksums.standard).toBe('282d3c8ce440b8a03698196967042a08'); + }); + + it('surfaces the repo canonical_url rather than echoing the requested spelling', async () => { + const httpClient = { + post: jest.fn(async () => ({ + data: [oclEntry({ + url: '/orgs/MS/sources/BRTabelaSUS/', + canonicalUrl: 'https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS' + })] + })) + }; + const resolver = makeResolver({ httpClient }); + + // Asked with a different spelling than the repo's own canonical. + const r = await resolver.resolve('http://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS'); + + expect(r.canonical).toBe('https://terminologia.saude.gov.br/fhir/CodeSystem/BRTabelaSUS'); + }); + + it('surfaces url_registry_entry (Phase 2 sandbox needs it)', async () => { + const httpClient = { + post: jest.fn(async () => ({ + data: [ + oclEntry({ + registryEntry: '/orgs/MyOrg/url-registry/1/', + resolutionUrl: 'http://terminology.hl7.org/CodeSystem/x' + }) + ] + })) + }; + const resolver = makeResolver({ httpClient, org: 'MyOrg' }); + + const result = await resolver.resolve('http://terminology.hl7.org/CodeSystem/x|0.1.0'); + + expect(result.registryEntry).toBe('/orgs/MyOrg/url-registry/1/'); + expect(result.resolutionUrl).toBe('http://terminology.hl7.org/CodeSystem/x'); + }); + + it('returns an empty array and issues no request for no references', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + await expect(resolver.resolveReferences([])).resolves.toEqual([]); + expect(httpClient.post).not.toHaveBeenCalled(); + }); + + it('reports resolved:false without throwing', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: [oclEntry({ resolved: false })] })) + }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/nope/sources/nope/'); + + expect(result.resolved).toBe(false); + expect(result.repoUrl).toBeNull(); + }); + + it('treats a resolved flag with no result url as unresolved', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: [{ resolved: true, result: null }] })) + }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/A/sources/S/'); + + expect(result.resolved).toBe(false); + }); + + it.each([[null], ['oops'], [42]])('treats a malformed result entry %p as unresolved', async entry => { + const httpClient = { post: jest.fn(async () => ({ data: [entry] })) }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/A/sources/S/'); + + expect(result.resolved).toBe(false); + expect(result.repoUrl).toBeNull(); + }); + + it('tolerates a non-array response body', async () => { + const httpClient = { + post: jest.fn(async () => ({ data: oclEntry({ url: '/orgs/A/sources/S/HEAD/' }) })) + }; + const resolver = makeResolver({ httpClient }); + + const result = await resolver.resolve('/orgs/A/sources/S/'); + + expect(result.repoUrl).toBe('/orgs/A/sources/S/HEAD/'); + }); + + it('tolerates a null response body', async () => { + const httpClient = { post: jest.fn(async () => ({ data: null })) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + const result = await resolver.resolve('/orgs/A/sources/S/'); + + expect(result.resolved).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/misaligned/)); + }); +}); + +describe('OclReferenceResolver batching', () => { + // OCL discourages the per-reference namespace field, so we group by namespace and + // use the request-level query parameter instead. + it('groups by namespace: one POST per namespace, order preserved, no per-ref namespace', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient, org: 'Default' }); + + const results = await resolver.resolveReferences([ + 'a', + { url: 'b', namespace: '/orgs/Other/' }, + 'c' + ]); + + expect(httpClient.post).toHaveBeenCalledTimes(2); + + const [firstCall, secondCall] = httpClient.post.mock.calls; + expect(firstCall[1]).toEqual(['a', 'c']); + expect(firstCall[2]).toEqual({ params: { namespace: '/orgs/Default/' } }); + expect(secondCall[1]).toEqual([{ url: 'b' }]); + expect(secondCall[2]).toEqual({ params: { namespace: '/orgs/Other/' } }); + + // The namespace field must never appear in the body. + expect(JSON.stringify(secondCall[1])).not.toContain('namespace'); + + // Caller order survives the regrouping. + expect(results.map(r => r.repoUrl)).toEqual([ + '/orgs/X/sources/a/HEAD/', + '/orgs/X/sources/b/HEAD/', + '/orgs/X/sources/c/HEAD/' + ]); + }); + + it('honours a per-call namespace override', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient, org: 'Default' }); + + await resolver.resolveReferences(['a'], { namespace: '/users/joe/' }); + + expect(httpClient.post.mock.calls[0][2]).toEqual({ params: { namespace: '/users/joe/' } }); + }); + + it('discards a group whose result count does not match the request count', async () => { + // Never let result[0] be attributed to the wrong canonical. + const httpClient = { + post: jest.fn(async () => ({ data: [oclEntry({ url: '/orgs/A/sources/only/HEAD/' })] })) + }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + const results = await resolver.resolveReferences(['a', 'b']); + + expect(results).toHaveLength(2); + expect(results.every(r => r.resolved === false)).toBe(true); + expect(logger.error).toHaveBeenCalledWith( + expect.stringMatching(/returned 1 result\(s\) for 2 reference\(s\).*misaligned/) + ); + }); +}); + +describe('OclReferenceResolver caching', () => { + it('serves a repeat reference from cache', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + const first = await resolver.resolve('/orgs/A/sources/S/'); + const second = await resolver.resolve('/orgs/A/sources/S/'); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + expect(second).toEqual(first); + }); + + // The same canonical legitimately resolves differently per namespace; a + // namespace-blind cache key would cross-contaminate them. + it('does not collide across namespaces', async () => { + const httpClient = { + post: jest.fn(async (path, body, config) => ({ + data: body.map(() => oclEntry({ url: `${config.params.namespace}sources/S/HEAD/` })) + })) + }; + const resolver = makeResolver({ httpClient }); + + const a = await resolver.resolve('S', { namespace: '/orgs/A/' }); + const b = await resolver.resolve('S', { namespace: '/orgs/B/' }); + + expect(httpClient.post).toHaveBeenCalledTimes(2); + expect(a.repoUrl).toBe('/orgs/A/sources/S/HEAD/'); + expect(b.repoUrl).toBe('/orgs/B/sources/S/HEAD/'); + }); + + it('distinguishes references that differ only by code', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + await resolver.resolve({ url: 'S', code: '1' }); + await resolver.resolve({ url: 'S', code: '2' }); + + expect(httpClient.post).toHaveBeenCalledTimes(2); + }); + + it('mixes cached and uncached references in one call', async () => { + const httpClient = echoClient(); + const resolver = makeResolver({ httpClient }); + + await resolver.resolve('a'); + httpClient.post.mockClear(); + + const results = await resolver.resolveReferences(['a', 'b']); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + expect(httpClient.post.mock.calls[0][1]).toEqual(['b']); + expect(results.map(r => r.repoUrl)).toEqual([ + '/orgs/X/sources/a/HEAD/', + '/orgs/X/sources/b/HEAD/' + ]); + }); + + it('does not cache a failed resolution', async () => { + const httpClient = { + post: jest + .fn() + .mockRejectedValueOnce(httpError(400, { detail: 'bad' })) + .mockResolvedValueOnce({ data: [oclEntry({ url: '/orgs/A/sources/S/HEAD/' })] }) + }; + const resolver = makeResolver({ httpClient, logger: silentLogger() }); + + const first = await resolver.resolve('/orgs/A/sources/S/'); + const second = await resolver.resolve('/orgs/A/sources/S/'); + + expect(first.resolved).toBe(false); + expect(second.resolved).toBe(true); + expect(httpClient.post).toHaveBeenCalledTimes(2); + }); +}); + +describe('OclReferenceResolver error handling', () => { + it('disables itself and falls back when the endpoint is missing (404)', async () => { + const httpClient = { post: jest.fn().mockRejectedValue(httpError(404)) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + await expect(resolver.resolveReferences(['a'])).resolves.toBeNull(); + expect(resolver.isEnabled()).toBe(false); + expect(resolver.disabledReason).toBe('endpoint not implemented (404)'); + expect(logger.info).toHaveBeenCalledWith(expect.stringMatching(/not available/)); + + // Stays disabled: no further requests. + await expect(resolver.resolveReferences(['b'])).resolves.toBeNull(); + expect(httpClient.post).toHaveBeenCalledTimes(1); + }); + + it.each([[401], [403]])('disables itself and falls back on %i', async status => { + const httpClient = { post: jest.fn().mockRejectedValue(httpError(status)) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + await expect(resolver.resolveReferences(['a'])).resolves.toBeNull(); + expect(resolver.isEnabled()).toBe(false); + expect(resolver.disabledReason).toBe(`not authorised (${status})`); + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/credentials/)); + }); + + // A 400 is our bug, not the instance's — log it loudly but stay enabled, since a + // later well-formed batch may be fine. + it('reports a 400 without disabling', async () => { + const httpClient = { post: jest.fn().mockRejectedValue(httpError(400, { detail: 'malformed' })) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + const results = await resolver.resolveReferences(['a', 'b']); + + expect(results).toHaveLength(2); + expect(results.every(r => r.resolved === false)).toBe(true); + expect(resolver.isEnabled()).toBe(true); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('malformed')); + }); + + it('falls back on a network timeout without disabling', async () => { + const httpClient = { post: jest.fn().mockRejectedValue(new Error('timeout of 30000ms exceeded')) }; + const logger = silentLogger(); + const resolver = makeResolver({ httpClient, logger }); + + await expect(resolver.resolveReferences(['a'])).resolves.toBeNull(); + expect(resolver.isEnabled()).toBe(true); + expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/timeout/)); + }); + + it('falls back wholesale when one group fails mid-flight', async () => { + // Half-resolved sets are worse than none: the caller would mix resolver output + // with its fallback path. + const httpClient = { + post: jest + .fn() + .mockResolvedValueOnce({ data: [oclEntry({ url: '/orgs/A/sources/S/HEAD/' })] }) + .mockRejectedValueOnce(httpError(401)) + }; + const resolver = makeResolver({ httpClient, logger: silentLogger(), org: 'A' }); + + const results = await resolver.resolveReferences(['a', { url: 'b', namespace: '/orgs/B/' }]); + + expect(results).toBeNull(); + }); +}); diff --git a/tx/ocl/README.md b/tx/ocl/README.md index c46324c2..98a4a016 100644 --- a/tx/ocl/README.md +++ b/tx/ocl/README.md @@ -31,6 +31,7 @@ In FHIRsmith terms, these providers are loaded by `tx/library.js` when a source - `jobs/background-queue.js`: singleton keyed queue, size-priority ordering, max concurrency = 2, heartbeat logging every 30s. - `mappers/concept-mapper.js`: maps OCL concept payloads to internal concept context shape. - `model/concept-filter-context.js`: ranked filter result set used by CodeSystem filters. +- `resolve/reference-resolver.js`: client for OCL's `$resolveReference` — resolves a canonical URL to the repo that holds it. Requires a token; see [Canonical resolution via `$resolveReference`](#canonical-resolution-via-resolvereference). - `shared/constants.js`: defaults and constants (`PAGE_SIZE`, cache freshness window, etc.). - `shared/patches.js`: - patches search worker so `CodeSystem?url=...&code=...` on OCL resources only returns matching concept subtree. @@ -131,10 +132,12 @@ ocl:|org=|token=|timeout= Parsed keys: - `baseUrl` (required) -- `org` (optional) -- `token` (optional) +- `org` (optional) — scopes discovery, and derives the `$resolveReference` namespace (`/orgs/{org}/`) +- `token` (optional) — enables `$resolveReference`; without it that operation is not used - `timeout` (optional positive number) +Unrecognised keys are ignored silently, so a typo (`namepsace=...`) is dropped rather than reported. + Examples: ```yaml @@ -163,6 +166,61 @@ sources: - Token is optional, sent as `Authorization`: - `Token ` if no prefix is provided - preserved if already `Token ...` or `Bearer ...` +- **A token is what enables `$resolveReference`.** It remains optional and no existing + configuration needs to change — but see below for what you gain by adding one. + +> **Know where the token ends up.** There is no environment-variable interpolation in +> `tx/library.js` — the `ocl:` alias block is read from the same YAML — so the token's +> only home is the library file, and that file is normally tracked by git. A token +> committed there is a live credential in your history: it is readable by anyone with +> repository access, and rotating it at OCL (not deleting the line) is the only way to +> revoke it afterwards. +> +> That can be a reasonable trade in a **private** deployment repository whose purpose +> is to carry environment config. Do not do it in a public one, and do not let the +> library file travel with a contribution upstream. + +### Canonical resolution via `$resolveReference` + +OCL's [`$resolveReference`](https://docs.openconceptlab.org/en/latest/oclapi/apireference/resolveReference.html) +answers *"which repo holds this canonical URL?"* authoritatively, in one call. +Without it, `cm-ocl.js` has to guess: it derives a search token from the canonical, +pages through `/orgs/{org}/sources/?q=...`, and matches `canonical_url` on whatever +the search happens to surface. + +**A token is required, and this is the one thing worth understanding here.** +`$resolveReference` is authenticated on every OCL instance we have probed, while the +`/orgs/` enumeration it replaces is public. So a tokenless server can still discover +and serve OCL content exactly as before, but it cannot use `$resolveReference`. + +**Nothing breaks without a token.** The resolver is constructed disabled when no token +is configured, and every caller falls back to the path it used previously. Existing +`ocl:` source lines keep working unchanged; adding a token is opt-in: + +| Configuration | Canonical → repo resolution | Behaviour | +| --- | --- | --- | +| `ocl:https://ocl.example.org` | `/orgs/{org}/sources/?q=` search | unchanged from previous versions | +| `ocl:https://ocl.example.org\|token=...` | `$resolveReference` | authoritative, one request, falls back to the search if it fails | + +The resolver also disables itself permanently — falling back for the rest of the +process — if the instance answers `404` (operation not implemented) or `401`/`403` +(credentials rejected). A `400` is logged as a bug on our side without disabling. + +**Namespace.** `$resolveReference` evaluates a reference within a *namespace*, which +FHIRSmith derives from the source entry: + +- `org=X` configured → namespace `/orgs/X/` +- no `org` → `/` (global), which is OCL's own default + +A namespace is a *preference*, not a boundary: OCL consults the owner's URL Registry, +then repos in the namespace, then falls through to the **Global URL Registry**. A +resolve scoped to `/orgs/A/` can therefore still return another owner's repo. Confining +resolution to a namespace (the "sandbox" case) would have to be enforced on this side +and is not implemented. + +**What it does not do:** `$resolveReference` resolves a *known* reference; it cannot +*list* what exists. Source/collection discovery still uses the `/orgs/` enumeration +described above. ### Enable/disable integration - Enable by including at least one `ocl:` source entry in TX library YAML. @@ -200,11 +258,15 @@ Typical events: - If cache never warms, check cold-cache timestamps and 1-hour rule. - If searches by `code` behave unexpectedly, confirm OCL marker extension is present on CodeSystem resources. - For ValueSet filter behavior, verify `filter` is included in request path through `TxParameters.hashSource` patch and that filter text normalizes as expected. +- If `$resolveReference` seems to be ignored, check for a `[OCL] $resolveReference ...` log line explaining why it disabled itself. Silence usually means no `token=` is configured, in which case it never runs at all. ### Known limitations (from current implementation) - OCL checksums are not relied on for cache invalidation. - Some source/collection discovery paths depend on OCL endpoint support and can fallback. - Missing endpoints returning `404` in some concept fetch paths are treated as empty content for graceful degradation. +- `$resolveReference` is only used for ConceptMap source canonicals (`cm-ocl.js`). `cs-ocl.js`/`vs-ocl.js` still build repo paths by hand, but only as a last resort behind the `concepts_url` / `expansion_url` that OCL supplies in its payloads. +- Namespace sandboxing is not implemented: a namespaced resolve can still fall through to OCL's Global URL Registry and return a repo outside that namespace. +- There is no way to supply a token outside the tracked library YAML. ## Developer notes ### Where to extend @@ -213,6 +275,7 @@ Typical events: - Add queue policy: `tx/ocl/jobs/background-queue.js` - Add mapping logic from OCL payloads: `tx/ocl/mappers/*` - Add fingerprint strategy: `tx/ocl/fingerprint/*` +- Add canonical-resolution behavior: `tx/ocl/resolve/reference-resolver.js` - Add provider-specific behavior: - CodeSystem: `tx/ocl/cs-ocl.js` - ValueSet: `tx/ocl/vs-ocl.js` @@ -220,6 +283,7 @@ Typical events: ### Ownership by concern - HTTP access: `http/client.js`, `http/pagination.js` +- Canonical URL to OCL repo resolution: `resolve/reference-resolver.js` - Disk cold cache and pathing: `cache/cache-paths.js`, `cache/cache-utils.js` - Background jobs and scheduling policy: `jobs/background-queue.js` - Fingerprints/checksums: `fingerprint/fingerprint.js` and provider compare logic @@ -234,3 +298,13 @@ Typical events: ```bash npm test -- --runInBand tests/ocl --coverage --collectCoverageFrom="tx/ocl/**/*.js" ``` + +> **Read that number carefully.** Most of `tx/ocl` is `.cjs` with a one-line `.js` +> re-export stub beside it, and the glob above (like `collectCoverageFrom` in +> `jest.config.js`) matches `**/*.js` but **not** `**/*.cjs`. So for `cs-ocl`, `vs-ocl` +> and `cm-ocl` it reports on the stubs, not the implementation. Only genuine `.js` +> modules — currently `resolve/reference-resolver.js` — are really measured: +> +> ```bash +> npx jest --testPathPattern "tests/ocl" --coverage --collectCoverageFrom="tx/ocl/resolve/**/*.js" +> ``` diff --git a/tx/ocl/cm-ocl.cjs b/tx/ocl/cm-ocl.cjs index d726ef0a..ca45ed2e 100644 --- a/tx/ocl/cm-ocl.cjs +++ b/tx/ocl/cm-ocl.cjs @@ -3,6 +3,7 @@ const { ConceptMap } = require('../library/conceptmap'); const { PAGE_SIZE } = require('./shared/constants'); const { createOclHttpClient } = require('./http/client'); const { fetchAllPages, extractItemsAndNext } = require('./http/pagination'); +const { OclReferenceResolver, isOclRepoPath } = require('./resolve/reference-resolver'); const DEFAULT_MAX_SEARCH_PAGES = 10; @@ -22,6 +23,15 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { this._sourceCandidatesCache = new Map(); this._sourceUrlsByCanonical = new Map(); this._canonicalBySourceUrl = new Map(); + + // Asks OCL which repo holds a canonical instead of guessing via text search. + // Disabled unless a token is configured, in which case #candidateSourceUrls + // keeps using its existing search. + this.referenceResolver = new OclReferenceResolver({ + httpClient: this.httpClient, + org: this.org, + token: options.token || null + }); } assignIds(ids) { @@ -107,8 +117,13 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { async searchConceptMaps(searchParams, _elements) { this._validateSearchParams(searchParams); + // Keep the canonical exactly as supplied. Lower-casing it here was harmless while + // every consumer compared through #norm() (which lower-cases anyway), but + // $resolveReference matches the canonical exactly, and OCL has no + // .../fhir/codesystem/alcoolspa_uso_mangara -- only .../fhir/CodeSystem/AlcoolSPA_uso_Mangara. + // findConceptMapForTranslation already passes the caller's original casing. const params = Object.fromEntries( - searchParams.map(({ name, value }) => [name, String(value).toLowerCase()]) + searchParams.map(({ name, value }) => [name, String(value)]) ); const sourceSystem = params['source-system'] || params.source || null; const targetSystem = params['target-system'] || params.target || null; @@ -131,7 +146,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { async #collectMappingsForSearch(sourceSystem, targetSystem) { const systemUrl = sourceSystem || targetSystem; const candidates = await this.#candidateSourceUrls(systemUrl); - const sourcePaths = candidates.filter(s => String(s || '').startsWith('/orgs/')); + const sourcePaths = candidates.filter(isOclRepoPath); if (sourcePaths.length === 0) { return []; @@ -140,30 +155,19 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { const allMappings = []; for (const sourcePath of sourcePaths) { const normalizedPath = this.#normalizeSourcePath(sourcePath); - let concepts; try { - concepts = await this.#fetchAllPages( - `${normalizedPath}concepts/`, { limit: PAGE_SIZE }, this.maxSearchPages + // Ask the source for its mappings directly. Walking concepts and asking each + // one costs a request per concept and, worse, only ever sees the first + // maxSearchPages of them -- for LOINC that is 1000 of 184683 concepts (0.5%), + // so any mapping past that point was silently invisible. + const mappings = await this.#fetchAllPages( + `${normalizedPath}mappings/`, { limit: PAGE_SIZE }, this.maxSearchPages ); + allMappings.push(...mappings); } catch (_err) { + // source exposes no mappings endpoint or is inaccessible — skip continue; } - - for (const concept of concepts) { - const code = concept.id || concept.mnemonic; - if (!code) { - continue; - } - try { - const mappings = await this.#fetchAllPages( - `${normalizedPath}concepts/${encodeURIComponent(code)}/mappings/`, - { limit: PAGE_SIZE }, 2 - ); - allMappings.push(...mappings); - } catch (_err) { - // concept has no mappings or endpoint inaccessible — skip - } - } } const sourceUrlsToResolve = new Set(); @@ -283,7 +287,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { const targetCandidates = await this.#candidateSourceUrls(targetSystem); const mappings = []; - const sourcePaths = sourceCandidates.filter(s => String(s || '').startsWith('/orgs/')); + const sourcePaths = sourceCandidates.filter(isOclRepoPath); if (sourceCode && sourcePaths.length > 0) { for (const sourcePath of sourcePaths) { @@ -515,9 +519,16 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { } } - const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl); - for (const item of discovered) { - result.add(item); + // Ask OCL which repo holds this canonical (#2261). An authoritative answer makes + // the heuristic text search below unnecessary; without one we fall back to it. + const resolved = await this.#resolveViaReferenceResolver(systemUrl); + if (resolved) { + result.add(resolved); + } else { + const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl); + for (const item of discovered) { + result.add(item); + } } const out = Array.from(result); @@ -525,6 +536,59 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { return out; } + /** + * Resolve a canonical to its OCL repo via $resolveReference. + * Returns null when the resolver is disabled (no token), when the endpoint is + * unavailable, or when OCL cannot resolve the canonical — in every case the + * caller falls back to the existing search. + */ + async #resolveViaReferenceResolver(systemUrl) { + // Say why once, rather than per lookup: for a tokenless deployment this is the + // expected steady state, not an error. + if (!this.referenceResolver.isEnabled()) { + if (!this._resolverDisabledLogged) { + this._resolverDisabledLogged = true; + console.log( + `[OCL] $resolveReference not in use (${this.referenceResolver.disabledReason}); using source search` + ); + } + return null; + } + + let resolved; + try { + resolved = await this.referenceResolver.resolve(systemUrl); + } catch (error) { + console.warn(`[OCL] $resolveReference lookup failed for ${systemUrl}: ${error.message}`); + return null; + } + + if (!resolved?.resolved || !resolved.repoUrl) { + console.log(`[OCL] $resolveReference did not resolve ${systemUrl}; falling back to source search`); + return null; + } + + // Logged on success too: resolver and search return the same thing, so without + // this there is no way -- from outside or from the logs -- to tell which path ran. + const via = resolved.registryEntry ? `url registry ${resolved.registryEntry}` : 'namespace'; + console.log( + `[OCL] $resolveReference resolved ${systemUrl} -> ${resolved.repoUrl} (namespace ${resolved.namespace}, via ${via})` + ); + + // Keep the canonical<->repo caches coherent with the search path's bookkeeping. + // Index under what was asked, but record OCL's own canonical_url as the repo's + // canonical: echoing the request back would store whatever spelling the caller + // used rather than the repo's actual identity. + const canonicalKey = this.#norm(systemUrl); + if (!this._sourceUrlsByCanonical.has(canonicalKey)) { + this._sourceUrlsByCanonical.set(canonicalKey, new Set()); + } + this._sourceUrlsByCanonical.get(canonicalKey).add(resolved.repoUrl); + this._canonicalBySourceUrl.set(this.#norm(resolved.repoUrl), resolved.canonical || systemUrl); + + return resolved.repoUrl; + } + async #resolveSourceCandidatesFromOcl(systemUrl) { const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/sources/` : '/sources/'; const query = this.#queryTokenFromSystem(systemUrl); @@ -572,7 +636,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider { } const sourcePath = String(sourceUrl || '').trim(); - if (!sourcePath.startsWith('/orgs/')) { + if (!isOclRepoPath(sourcePath)) { continue; } diff --git a/tx/ocl/resolve/reference-resolver.js b/tx/ocl/resolve/reference-resolver.js new file mode 100644 index 00000000..d268c46f --- /dev/null +++ b/tx/ocl/resolve/reference-resolver.js @@ -0,0 +1,367 @@ +// Client for OCL's $resolveReference operation. +// +// Resolves a canonical URL (or a relative OCL path) to the OCL repo that holds it, +// instead of hand-building /orgs/{owner}/sources/{id}/ paths. See +// https://docs.openconceptlab.org/en/latest/oclapi/apireference/resolveReference.html +// +// This is plain .js rather than the .cjs + stub convention used elsewhere in tx/ocl: +// jest's collectCoverageFrom globs **/*.js and not **/*.cjs, so a .cjs module here +// would be invisible to coverage. + +const RESOLVE_PATH = '/$resolveReference/'; +const GLOBAL_NAMESPACE = '/'; +const OWNER_TYPES = new Set(['orgs', 'users']); + +// OCL repos are owned by an org OR a user. Matching only /orgs/ silently drops +// every user-owned repo. +const REPO_PATH_PATTERN = /^\/(orgs|users)\/[^/]+\//; + +// namespace is deliberately absent: OCL discourages the per-reference namespace field, +// preferring the request-level query parameter, so we group by namespace instead. +const BODY_FIELDS = [ + 'url', + 'version', + 'code', + 'display', + 'id', + 'filter', + 'cascade', + 'includeExclude', + 'resourceType' +]; + +/** + * True for a relative OCL repo path under either owner type, e.g. + * `/orgs/CIEL/sources/CIEL/` or `/users/joe/sources/S/`. + */ +function isOclRepoPath(value) { + return REPO_PATH_PATTERN.test(String(value == null ? '' : value).trim()); +} + +/** + * Normalize a namespace to OCL's `/:ownerType/:owner/` form, or `/` for global. + * A malformed namespace throws rather than degrading to global, which would look + * fine while silently resolving against the wrong context. + */ +function normalizeNamespace(value) { + const text = String(value == null ? '' : value).trim(); + if (text === '' || text === GLOBAL_NAMESPACE) { + return GLOBAL_NAMESPACE; + } + + // A bare token with no slashes is an org id: `CIEL` -> `/orgs/CIEL/` + if (!text.includes('/')) { + return `/orgs/${text}/`; + } + + const normalized = `/${text.replace(/^\/+/, '').replace(/\/+$/, '')}/`; + const segments = normalized.slice(1, -1).split('/'); + if (segments.length !== 2 || !OWNER_TYPES.has(segments[0]) || !segments[1]) { + throw new Error( + `Invalid OCL namespace '${value}': expected '/' or '/orgs/{id}/' or '/users/{id}/'` + ); + } + + return normalized; +} + +/** + * Effective namespace for a source. Explicit namespace wins; otherwise derive from + * `org`, which is the only namespace-bearing config that reaches tx/ocl today. With a + * global namespace an org's own URL Registry is never consulted, which is not what a + * source pinned to org=X wants. + */ +function deriveNamespace({ namespace = null, org = null } = {}) { + if (namespace != null && String(namespace).trim() !== '') { + return normalizeNamespace(namespace); + } + if (org != null && String(org).trim() !== '') { + return normalizeNamespace(org); + } + return GLOBAL_NAMESPACE; +} + +/** + * Accepts either a relative/canonical URL string or an expanded reference object. + * Returns the request body form plus the namespace hint used for grouping. + */ +function normalizeReference(ref) { + if (typeof ref === 'string') { + const url = ref.trim(); + if (!url) { + throw new Error('OCL reference string cannot be empty'); + } + return { url, namespace: null, body: url }; + } + + if (!ref || typeof ref !== 'object' || Array.isArray(ref)) { + throw new Error(`Invalid OCL reference: expected a string or object, got ${typeof ref}`); + } + + const url = String(ref.url == null ? '' : ref.url).trim(); + if (!url) { + throw new Error('OCL reference object requires a url'); + } + + const body = {}; + for (const field of BODY_FIELDS) { + if (ref[field] !== undefined && ref[field] !== null) { + body[field] = ref[field]; + } + } + + return { url, namespace: ref.namespace == null ? null : ref.namespace, body }; +} + +function cacheKey(namespace, body) { + return `${namespace}|${typeof body === 'string' ? body : JSON.stringify(body)}`; +} + +function unresolved(namespace, request) { + return { + resolved: false, + repoUrl: null, + canonical: null, + ownerType: null, + resolutionUrl: null, + registryEntry: null, + referenceType: null, + namespace, + request, + result: null + }; +} + +function normalizeResult(entry, namespace, request) { + if (!entry || typeof entry !== 'object') { + return unresolved(namespace, request); + } + + const result = entry.result && typeof entry.result === 'object' ? entry.result : null; + const repoUrl = result && result.url ? result.url : null; + + return { + resolved: Boolean(entry.resolved) && Boolean(repoUrl), + repoUrl, + // OCL returns the repo's own canonical_url and owner_type. Prefer them over + // anything the caller assumed: they are authoritative, and echoing the request + // back would record whatever spelling the caller happened to use. + canonical: result ? (result.canonical_url || result.canonicalUrl || null) : null, + ownerType: result ? (result.owner_type || result.ownerType || null) : null, + resolutionUrl: entry.resolution_url || null, + // Phase 2's namespace sandbox needs this to tell owner-registry delegation + // apart from a global-registry fallthrough, so never drop it. + registryEntry: entry.url_registry_entry || null, + referenceType: entry.reference_type || null, + namespace, + request: entry.request === undefined ? request : entry.request, + result + }; +} + +class OclReferenceResolver { + #httpClient; + #namespace; + #logger; + #cache = new Map(); + #enabled; + #disabledReason = null; + + /** + * @param {object} options + * @param {object} options.httpClient - axios instance from createOclHttpClient + * @param {string} [options.namespace] - explicit namespace; wins over org + * @param {string} [options.org] - org id, used to derive the namespace + * @param {string} [options.token] - when absent the resolver stays disabled + * @param {object} [options.logger] + */ + constructor({ httpClient, namespace = null, org = null, token = null, logger = console } = {}) { + if (!httpClient) { + throw new Error('OCL reference resolver requires an http client'); + } + + this.#httpClient = httpClient; + this.#namespace = deriveNamespace({ namespace, org }); + this.#logger = logger; + + // $resolveReference is auth-gated on every OCL instance we've probed, while the + // /orgs/ enumeration it replaces is public. Without a token every call is a + // guaranteed 401, so stay disabled and let the caller use its existing path. + this.#enabled = Boolean(token); + if (!this.#enabled) { + this.#disabledReason = 'no token configured'; + } + } + + get namespace() { + return this.#namespace; + } + + isEnabled() { + return this.#enabled; + } + + get disabledReason() { + return this.#disabledReason; + } + + #disable(reason) { + this.#enabled = false; + this.#disabledReason = reason; + } + + /** + * Resolve one reference. Returns null when the resolver is unavailable, so the + * caller falls back to its existing path. + */ + async resolve(ref, options = {}) { + const results = await this.resolveReferences([ref], options); + return results ? results[0] : null; + } + + /** + * Resolve many references in as few round trips as possible. + * + * References are grouped by namespace and each group is sent as one POST with a + * `namespace` query parameter. Results are returned in the caller's original order. + * + * @returns {Promise} null when the resolver is unavailable (use fallback) + */ + async resolveReferences(refs, { namespace = null } = {}) { + if (!this.#enabled) { + return null; + } + + const list = Array.isArray(refs) ? refs : [refs]; + if (list.length === 0) { + return []; + } + + const defaultNamespace = namespace == null ? this.#namespace : normalizeNamespace(namespace); + + const normalized = list.map(ref => { + const entry = normalizeReference(ref); + return { + ...entry, + effectiveNamespace: entry.namespace == null + ? defaultNamespace + : normalizeNamespace(entry.namespace) + }; + }); + + const output = new Array(normalized.length).fill(null); + const groups = new Map(); + + normalized.forEach((entry, index) => { + const key = cacheKey(entry.effectiveNamespace, entry.body); + if (this.#cache.has(key)) { + output[index] = this.#cache.get(key); + return; + } + + const group = groups.get(entry.effectiveNamespace) || []; + group.push({ entry, index }); + groups.set(entry.effectiveNamespace, group); + }); + + for (const [groupNamespace, members] of groups) { + const resolved = await this.#resolveGroup(groupNamespace, members); + if (resolved === null) { + // Resolver became unavailable mid-flight; caller falls back wholesale rather + // than acting on a half-resolved set. + return null; + } + for (const { index, value } of resolved) { + output[index] = value; + } + } + + return output; + } + + async #resolveGroup(groupNamespace, members) { + const body = members.map(({ entry }) => entry.body); + + let response; + try { + response = await this.#httpClient.post(RESOLVE_PATH, body, { + params: { namespace: groupNamespace } + }); + } catch (error) { + return this.#handleError(error, groupNamespace, members); + } + + const payload = Array.isArray(response?.data) + ? response.data + : response?.data == null + ? [] + : [response.data]; + + // Results are positional. If the count doesn't match we cannot know which result + // belongs to which reference, so treat the whole group as unresolved rather than + // silently attributing a resolution to the wrong canonical. + if (payload.length !== members.length) { + this.#logger.error( + `[OCL] $resolveReference returned ${payload.length} result(s) for ${members.length} reference(s) in namespace ${groupNamespace}; discarding group to avoid misaligned results` + ); + return members.map(({ entry, index }) => ({ + index, + value: unresolved(groupNamespace, entry.body) + })); + } + + return members.map(({ entry, index }, position) => { + const value = normalizeResult(payload[position], groupNamespace, entry.body); + this.#cache.set(cacheKey(groupNamespace, entry.body), value); + return { index, value }; + }); + } + + #handleError(error, groupNamespace, members) { + const status = error?.response?.status; + + if (status === 404) { + this.#disable('endpoint not implemented (404)'); + this.#logger.info( + `[OCL] $resolveReference is not available on this instance (404); using repo paths instead` + ); + return null; + } + + if (status === 401 || status === 403) { + this.#disable(`not authorised (${status})`); + this.#logger.warn( + `[OCL] $resolveReference rejected our credentials (${status}); using repo paths instead` + ); + return null; + } + + if (status === 400) { + // Our request body is wrong — a bug on this side. Don't disable: a later, + // well-formed batch may be fine. + const detail = error?.response?.data?.detail || error.message; + this.#logger.error( + `[OCL] $resolveReference rejected the request body in namespace ${groupNamespace}: ${detail}` + ); + return members.map(({ entry, index }) => ({ + index, + value: unresolved(groupNamespace, entry.body) + })); + } + + this.#logger.warn( + `[OCL] $resolveReference failed in namespace ${groupNamespace}: ${error.message}` + ); + return null; + } +} + +module.exports = { + OclReferenceResolver, + normalizeNamespace, + deriveNamespace, + normalizeReference, + isOclRepoPath, + GLOBAL_NAMESPACE, + RESOLVE_PATH +};