fhirsmith 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tx/ocl/README.md CHANGED
@@ -39,14 +39,70 @@ In FHIRsmith terms, these providers are loaded by `tx/library.js` when a source
39
39
  ## Runtime flow
40
40
  ### Metadata discovery
41
41
  CodeSystems (`cs-ocl.js`):
42
- - discover orgs via `/orgs/`
43
- - for each org, discover sources via `/orgs/{org}/sources/`
44
- - fallback to `/sources/` if org listing is unavailable
42
+ - prefer one paginated crawl of the global `/sources/` listing (filtered to
43
+ organization-owned entries)
44
+ - fallback: discover orgs via `/orgs/`, then `/orgs/{org}/sources/` per org
45
45
 
46
46
  ValueSets (`vs-ocl.js`):
47
- - discover orgs via `/orgs/`
48
- - discover collections via `/orgs/{org}/collections/`
49
- - fallback to `/collections/`
47
+ - prefer one paginated crawl of the global `/collections/` listing (filtered to
48
+ organization-owned entries)
49
+ - fallback: discover orgs via `/orgs/`, then `/orgs/{org}/collections/` per org
50
+
51
+ ### Visibility policy (org-only + public-only)
52
+ An artifact is expected to live in an **organization** to be visible through the
53
+ terminology service. User-owned repos (`/users/{user}/...`) are experimental by
54
+ convention and are excluded from both discovery and `$resolveReference` results
55
+ (a canonical resolving to a user-owned repo is treated as unresolved).
56
+
57
+ In addition, a repo is only served when it is **publicly viewable**
58
+ (`public_access` is `View`/`Edit`; `None`/unknown is treated as not servable,
59
+ fail-closed). Because the configured token can see every repo its OCL account has
60
+ access to — and both `$resolveReference` and discovery answer anonymous terminology
61
+ requests — without this gate a *private* repo in the token's org could be surfaced
62
+ publicly. This gate is applied in **both** paths:
63
+
64
+ - **`$resolveReference`**: a resolved repo that is not public is treated as unresolved.
65
+ - **Discovery**: the global (`/sources/`, `/collections/`) and per-org listing crawls
66
+ drop non-public (and non-org-owned) repos before they are registered.
67
+
68
+ `public_access` is not returned by the listing endpoints or by `$resolveReference`
69
+ today, so a shared `PublicAccessChecker` fetches each repo once to read it, caching
70
+ the verdict by `repoUrl` (bounded LRU) and running batch checks with a concurrency
71
+ limit. A check that *errors* is not cached, so a transient blip is retried rather
72
+ than pinning a repo as private. The resolver also rejects any `repoUrl` that is not
73
+ a safe same-host relative path, so an unexpected response can never redirect an
74
+ authenticated request off-host.
75
+
76
+ > **Operational guidance (defense-in-depth):** configure the `token=` account with
77
+ > the **least privilege** needed — ideally a service account that can see only
78
+ > public repos. The `public_access` gate enforces public-only serving in code
79
+ > regardless, but a least-privilege token means a misconfiguration can never
80
+ > expose private content.
81
+
82
+ ### Canonical resolution via `$resolveReference`
83
+ With a `token=` configured on the `ocl:` source line, the providers resolve
84
+ "which repo holds this canonical URL?" through OCL's
85
+ [`$resolveReference`](https://docs.openconceptlab.org/en/latest/oclapi/apireference/resolveReference.html)
86
+ (global namespace) instead of iterating listings and matching `canonical_url`
87
+ client-side. Used for ConceptMap source-system search, ValueSet lookup by
88
+ canonical, and batched resolution of a collection's compose source canonicals.
89
+
90
+ Without a token nothing changes: `$resolveReference` is authenticated on every
91
+ instance probed (while the listing endpoints are public), so the resolver is
92
+ constructed disabled and every caller keeps its previous search path. A `404`
93
+ (endpoint not implemented) disables it for the process; a transient `401`/`403`
94
+ backs it off for a cooldown and then retries. The resolution cache is a bounded
95
+ LRU and over-long references are never sent to OCL.
96
+
97
+ ### CodeSystem default versions (release vs HEAD)
98
+ OCL's own resolution treats a source's **latest release** as its default version
99
+ (HEAD only when nothing is released), but discovery listings only ever report
100
+ HEAD. With a token, discovery batch-resolves every canonical through
101
+ `$resolveReference` and registers **both** versions: the release becomes what a
102
+ versionless request gets (matching OCL), and HEAD stays reachable via an
103
+ explicit `version=HEAD`. Only canonicals that are new or changed are re-resolved
104
+ on refresh, so a steady-state cycle costs no extra requests. Without a token,
105
+ discovery stays HEAD-only as before.
50
106
 
51
107
  ConceptMaps (`cm-ocl.js`):
52
108
  - fetch by id via `/mappings/{id}/`
package/tx/ocl/cm-ocl.cjs CHANGED
@@ -4,9 +4,17 @@ const { PAGE_SIZE } = require('./shared/constants');
4
4
  const { createOclHttpClient } = require('./http/client');
5
5
  const { fetchAllPages, extractItemsAndNext } = require('./http/pagination');
6
6
  const { stripIdPrefix } = require('../library/resource-ids');
7
+ const { OclReferenceResolver, isOclRepoPath } = require('./resolve/reference-resolver');
8
+ const Logger = require('../../library/logger');
9
+ const oclCmLog = Logger.getInstance().child({ module: 'ocl-cm' });
7
10
 
8
11
  const DEFAULT_MAX_SEARCH_PAGES = 10;
9
12
 
13
+ // OCL map types that relate concepts without mapping one to the other, so they must
14
+ // not surface as $translate matches: CIEL uses Q-AND-A (question -> answer) and
15
+ // CONCEPT-SET (set -> member). Unknown types still map to related-to.
16
+ const NON_TRANSLATION_MAP_TYPES = new Set(['Q-AND-A', 'CONCEPT-SET']);
17
+
10
18
  class OCLConceptMapProvider extends AbstractConceptMapProvider {
11
19
  constructor(config = {}) {
12
20
  super();
@@ -23,6 +31,15 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
23
31
  this._sourceCandidatesCache = new Map();
24
32
  this._sourceUrlsByCanonical = new Map();
25
33
  this._canonicalBySourceUrl = new Map();
34
+
35
+ // Asks OCL which repo holds a canonical instead of guessing via text search.
36
+ // Disabled unless a token is configured, in which case #candidateSourceUrls
37
+ // keeps using its existing search.
38
+ this.referenceResolver = new OclReferenceResolver({
39
+ httpClient: this.httpClient,
40
+ token: options.token || null,
41
+ logger: oclCmLog
42
+ });
26
43
  }
27
44
 
28
45
  prefixIds() {
@@ -114,8 +131,12 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
114
131
  async searchConceptMaps(searchParams, _elements) {
115
132
  this._validateSearchParams(searchParams);
116
133
 
134
+ // Keep the canonical exactly as supplied. Lower-casing it was harmless while
135
+ // every consumer compared through #norm() (which lower-cases anyway), but
136
+ // $resolveReference matches the canonical exactly, and OCL has no
137
+ // .../fhir/codesystem/x -- only .../fhir/CodeSystem/X.
117
138
  const params = Object.fromEntries(
118
- searchParams.map(({ name, value }) => [name, String(value).toLowerCase()])
139
+ searchParams.map(({ name, value }) => [name, String(value)])
119
140
  );
120
141
  const sourceSystem = params['source-system'] || params.source || null;
121
142
  const targetSystem = params['target-system'] || params.target || null;
@@ -138,7 +159,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
138
159
  async #collectMappingsForSearch(sourceSystem, targetSystem) {
139
160
  const systemUrl = sourceSystem || targetSystem;
140
161
  const candidates = await this.#candidateSourceUrls(systemUrl);
141
- const sourcePaths = candidates.filter(s => String(s || '').startsWith('/orgs/'));
162
+ const sourcePaths = candidates.filter(isOclRepoPath);
142
163
 
143
164
  if (sourcePaths.length === 0) {
144
165
  return [];
@@ -147,30 +168,20 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
147
168
  const allMappings = [];
148
169
  for (const sourcePath of sourcePaths) {
149
170
  const normalizedPath = this.#normalizeSourcePath(sourcePath);
150
- let concepts;
151
171
  try {
152
- concepts = await this.#fetchAllPages(
153
- `${normalizedPath}concepts/`, { limit: PAGE_SIZE }, this.maxSearchPages
172
+ // Ask the source for its mappings directly. Walking concepts and asking
173
+ // each one costs a request per concept and, worse, only ever sees the
174
+ // first maxSearchPages of them -- for LOINC that is 1000 of 184683
175
+ // concepts (0.5%), so any mapping past that point was silently invisible.
176
+ // Verified live: both paths return the identical mapping set.
177
+ const mappings = await this.#fetchAllPages(
178
+ `${normalizedPath}mappings/`, { limit: PAGE_SIZE }, this.maxSearchPages
154
179
  );
180
+ allMappings.push(...mappings);
155
181
  } catch (_err) {
182
+ // source exposes no mappings endpoint or is inaccessible — skip
156
183
  continue;
157
184
  }
158
-
159
- for (const concept of concepts) {
160
- const code = concept.id || concept.mnemonic;
161
- if (!code) {
162
- continue;
163
- }
164
- try {
165
- const mappings = await this.#fetchAllPages(
166
- `${normalizedPath}concepts/${encodeURIComponent(code)}/mappings/`,
167
- { limit: PAGE_SIZE }, 2
168
- );
169
- allMappings.push(...mappings);
170
- } catch (_err) {
171
- // concept has no mappings or endpoint inaccessible — skip
172
- }
173
- }
174
185
  }
175
186
 
176
187
  const sourceUrlsToResolve = new Set();
@@ -194,7 +205,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
194
205
  const sourceCode = mapping.from_concept_code || mapping.fromConceptCode;
195
206
  const targetCode = mapping.to_concept_code || mapping.toConceptCode;
196
207
 
197
- if (!fromSource || !toSource || !sourceCode || !targetCode) {
208
+ if (!fromSource || !toSource || !sourceCode || !targetCode || !this.#isTranslation(mapping)) {
198
209
  continue;
199
210
  }
200
211
 
@@ -252,8 +263,8 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
252
263
  name: id,
253
264
  title: `${sourceId} to ${targetId}`,
254
265
  status: 'active',
255
- sourceScopeUri: group.sourceCanonical,
256
- targetScopeUri: group.targetCanonical,
266
+ // No sourceScope/targetScope: a scope is a ValueSet, and $translate resolves it and
267
+ // checks the code against it. The systems are carried by the group instead.
257
268
  group: [{
258
269
  source: group.sourceCanonical,
259
270
  target: group.targetCanonical,
@@ -290,7 +301,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
290
301
  const targetCandidates = await this.#candidateSourceUrls(targetSystem);
291
302
 
292
303
  const mappings = [];
293
- const sourcePaths = sourceCandidates.filter(s => String(s || '').startsWith('/orgs/'));
304
+ const sourcePaths = sourceCandidates.filter(isOclRepoPath);
294
305
 
295
306
  if (sourceCode && sourcePaths.length > 0) {
296
307
  for (const sourcePath of sourcePaths) {
@@ -410,7 +421,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
410
421
  const sourceCode = mapping.from_concept_code || mapping.fromConceptCode;
411
422
  const targetCode = mapping.to_concept_code || mapping.toConceptCode;
412
423
 
413
- if (!source || !target || !sourceCode || !targetCode) {
424
+ if (!source || !target || !sourceCode || !targetCode || !this.#isTranslation(mapping)) {
414
425
  return null;
415
426
  }
416
427
 
@@ -430,8 +441,10 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
430
441
  name: `mapping-${id}`,
431
442
  title: mapping.name || `Mapping ${id}`,
432
443
  status: 'active',
433
- sourceScopeUri: mapping.from_collection_url || mapping.fromCollectionUrl || source,
434
- targetScopeUri: mapping.to_collection_url || mapping.toCollectionUrl || target,
444
+ // No sourceScope/targetScope: a scope is a ValueSet canonical, which $translate resolves
445
+ // and checks the code against. OCL only offers repo paths here (from_source_url or
446
+ // from_collection_url), which resolve to no ValueSet and made every translation fail
447
+ // with not-found. The systems are carried by the group instead.
435
448
  group: [
436
449
  {
437
450
  source: sourceCanonical,
@@ -461,21 +474,32 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
461
474
  return new ConceptMap(json, 'R5');
462
475
  }
463
476
 
477
+ // OCL map_type expresses "from concept <relation> to concept", which lines up
478
+ // with the R5 ConceptMapRelationship codes (source <relation> target).
464
479
  #toRelationship(mapType) {
465
- switch ((mapType || '').toUpperCase()) {
480
+ switch (this.#normalizeMapType(mapType)) {
466
481
  case 'SAME-AS':
467
482
  return 'equivalent';
468
483
  case 'NARROWER-THAN':
469
- return 'narrower-than';
484
+ return 'source-is-narrower-than-target';
470
485
  case 'BROADER-THAN':
471
- return 'broader-than';
486
+ return 'source-is-broader-than-target';
472
487
  case 'NOT-EQUIVALENT':
488
+ case 'NOT-SAME-AS':
473
489
  return 'not-related-to';
474
490
  default:
475
491
  return 'related-to';
476
492
  }
477
493
  }
478
494
 
495
+ #isTranslation(mapping) {
496
+ return !NON_TRANSLATION_MAP_TYPES.has(this.#normalizeMapType(mapping.map_type || mapping.mapType));
497
+ }
498
+
499
+ #normalizeMapType(mapType) {
500
+ return String(mapType || '').trim().toUpperCase().replace(/[\s_]+/g, '-');
501
+ }
502
+
479
503
  async #searchMappings(params = {}, maxPages = this.maxSearchPages) {
480
504
  const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/mappings/` : '/mappings/';
481
505
  return await this.#fetchAllPages(endpoint, params, maxPages);
@@ -523,9 +547,16 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
523
547
  }
524
548
  }
525
549
 
526
- const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl);
527
- for (const item of discovered) {
528
- result.add(item);
550
+ // Ask OCL which repo holds this canonical. An authoritative answer makes the
551
+ // heuristic text search below unnecessary; without one we fall back to it.
552
+ const resolved = await this.#resolveViaReferenceResolver(systemUrl);
553
+ if (resolved) {
554
+ result.add(resolved);
555
+ } else {
556
+ const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl);
557
+ for (const item of discovered) {
558
+ result.add(item);
559
+ }
529
560
  }
530
561
 
531
562
  const out = Array.from(result);
@@ -533,6 +564,56 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
533
564
  return out;
534
565
  }
535
566
 
567
+ /**
568
+ * Resolve a canonical to its OCL repo via $resolveReference.
569
+ * Returns null when the resolver is disabled (no token), the endpoint is
570
+ * unavailable, or OCL cannot resolve the canonical -- in every case the caller
571
+ * falls back to the existing search.
572
+ */
573
+ async #resolveViaReferenceResolver(systemUrl) {
574
+ // Say why once, rather than per lookup: for a tokenless deployment this is the
575
+ // expected steady state, not an error.
576
+ if (!this.referenceResolver.isEnabled()) {
577
+ if (!this._resolverDisabledLogged) {
578
+ this._resolverDisabledLogged = true;
579
+ oclCmLog.info(
580
+ `$resolveReference not in use (${this.referenceResolver.disabledReason}); using source search`
581
+ );
582
+ }
583
+ return null;
584
+ }
585
+
586
+ let resolved;
587
+ try {
588
+ resolved = await this.referenceResolver.resolve(systemUrl);
589
+ } catch (error) {
590
+ oclCmLog.warn(`$resolveReference lookup failed for ${systemUrl}: ${error.message}`);
591
+ return null;
592
+ }
593
+
594
+ if (!resolved?.resolved || !resolved.repoUrl) {
595
+ oclCmLog.info(`$resolveReference did not resolve ${systemUrl}; falling back to source search`);
596
+ return null;
597
+ }
598
+
599
+ // Logged on success too: resolver and search return the same thing, so without
600
+ // this there is no way -- from outside or from the logs -- to tell which path ran.
601
+ oclCmLog.info(`$resolveReference resolved ${systemUrl} -> ${resolved.repoUrl}`);
602
+
603
+ // Keep the canonical<->repo caches coherent with the search path's bookkeeping.
604
+ // Index under what was asked, but record OCL's own canonical_url as the repo's
605
+ // canonical: echoing the request back would store whatever spelling the caller
606
+ // used rather than the repo's actual identity.
607
+ const canonicalKey = this.#norm(systemUrl);
608
+ if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
609
+ this._sourceUrlsByCanonical.set(canonicalKey, new Set());
610
+ }
611
+ this._sourceUrlsByCanonical.get(canonicalKey).add(resolved.repoUrl);
612
+ this._canonicalBySourceUrl.set(this.#norm(resolved.repoUrl), resolved.canonical || systemUrl);
613
+
614
+ return resolved.repoUrl;
615
+ }
616
+
536
617
  async #resolveSourceCandidatesFromOcl(systemUrl) {
537
618
  const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/sources/` : '/sources/';
538
619
  const query = this.#queryTokenFromSystem(systemUrl);
@@ -573,17 +654,52 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
573
654
  }
574
655
 
575
656
  async #ensureCanonicalForSourceUrls(sourceUrls) {
657
+ // Deduplicate the repo paths that still need a canonical.
658
+ const pending = [];
659
+ const seen = new Set();
576
660
  for (const sourceUrl of sourceUrls || []) {
577
661
  const sourceKey = this.#norm(sourceUrl);
578
- if (!sourceKey || this._canonicalBySourceUrl.has(sourceKey)) {
662
+ if (!sourceKey || this._canonicalBySourceUrl.has(sourceKey) || seen.has(sourceKey)) {
579
663
  continue;
580
664
  }
581
-
582
665
  const sourcePath = String(sourceUrl || '').trim();
583
- if (!sourcePath.startsWith('/orgs/')) {
666
+ if (!isOclRepoPath(sourcePath)) {
584
667
  continue;
585
668
  }
669
+ seen.add(sourceKey);
670
+ pending.push(sourcePath);
671
+ }
672
+
673
+ if (pending.length === 0) {
674
+ return;
675
+ }
676
+
677
+ // One $resolveReference batch instead of one GET per source: the result
678
+ // carries the repo's canonical_url. Anything the batch cannot resolve falls
679
+ // through to the per-source GETs below, so behaviour without a token (or on
680
+ // failure) is unchanged.
681
+ if (this.referenceResolver.isEnabled()) {
682
+ try {
683
+ const results = await this.referenceResolver.resolveReferences(pending);
684
+ if (Array.isArray(results)) {
685
+ for (let i = 0; i < pending.length; i++) {
686
+ const r = results[i];
687
+ if (!r?.resolved || !r.canonical) {
688
+ continue;
689
+ }
690
+ const resolvedSourceUrl = r.repoUrl || pending[i];
691
+ this.#recordCanonicalForSource(pending[i], resolvedSourceUrl, r.canonical);
692
+ }
693
+ }
694
+ } catch (error) {
695
+ oclCmLog.warn(`$resolveReference batch for source canonicals failed: ${error.message}`);
696
+ }
697
+ }
586
698
 
699
+ for (const sourcePath of pending) {
700
+ if (this._canonicalBySourceUrl.has(this.#norm(sourcePath))) {
701
+ continue;
702
+ }
587
703
  try {
588
704
  const response = await this.httpClient.get(sourcePath);
589
705
  const source = response.data || {};
@@ -592,13 +708,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
592
708
  if (!canonical) {
593
709
  continue;
594
710
  }
595
-
596
- const canonicalKey = this.#norm(canonical);
597
- if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
598
- this._sourceUrlsByCanonical.set(canonicalKey, new Set());
599
- }
600
- this._sourceUrlsByCanonical.get(canonicalKey).add(resolvedSourceUrl);
601
- this._canonicalBySourceUrl.set(this.#norm(resolvedSourceUrl), canonical);
711
+ this.#recordCanonicalForSource(sourcePath, resolvedSourceUrl, canonical);
602
712
  } catch (e) {
603
713
  // Ignore source lookup failures and continue resolving remaining sources.
604
714
  continue;
@@ -606,6 +716,18 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
606
716
  }
607
717
  }
608
718
 
719
+ #recordCanonicalForSource(requestedPath, resolvedSourceUrl, canonical) {
720
+ const canonicalKey = this.#norm(canonical);
721
+ if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
722
+ this._sourceUrlsByCanonical.set(canonicalKey, new Set());
723
+ }
724
+ this._sourceUrlsByCanonical.get(canonicalKey).add(resolvedSourceUrl);
725
+ this._canonicalBySourceUrl.set(this.#norm(resolvedSourceUrl), canonical);
726
+ // Also index under the exact path we were asked about, so the "already
727
+ // resolved" checks hit even when OCL's repo url differs in shape.
728
+ this._canonicalBySourceUrl.set(this.#norm(requestedPath), canonical);
729
+ }
730
+
609
731
  #queryTokenFromSystem(systemUrl) {
610
732
  const raw = String(systemUrl || '').trim().replace(/\/+$/, '');
611
733
  if (!raw) {