diff --git a/README.md b/README.md index acf7f4ba..8a3b11fb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This server provides a set of server-side services that are useful for the FHIR ## Services useful the community as a whole -* [TX Registry](registry/readme.md) - **Terminology System Registry** as [described by the terminology ecosystem specification](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig)(as running at http://tx.fhir.org/tx-reg) +* [TX Registry](registry/readme.md) - **Terminology System Registry** as [described by the terminology ecosystem specification](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig) (as running at http://tx.fhir.org/tx-reg) * [Package server](packages/readme.md) - **NPM-style FHIR package registry** with search, versioning, and downloads, consistent with the FHIR NPM Specification (as running at http://packages2.fhir.org/packages) * [XIG server](xig/readme.md) - **Comprehensive FHIR IG analytics** with resource breakdowns by version, authority, and realm (as running at http://packages2.fhir.org/packages) * [Publisher](publisher/readme.md) - FHIR publishing services (coming) @@ -43,6 +43,10 @@ There are 4 executable programs: Unless you're developing, you only need the first two +FHIRsmith is open source - see below, and you're welcome to use it for any kind of use. Note, +though, that if you support FHIRsmith commercially as part of a managed service or product, you +are required to be a Commercial Partner of HL7 - see (link to be provided). + ### Quick Start * Install FHIRSmith (using docker, or an NPM release, or just get the code by git) diff --git a/security.md b/security.md index e0a72c3e..586227a0 100644 --- a/security.md +++ b/security.md @@ -29,4 +29,7 @@ A typical NGINX configuration would be: limit_conn perip 50; limit_conn perserver 500; ``` +## SSL + +This server doesn't provide SSL support - use an NGINX reverse proxy for that. diff --git a/stats.js b/stats.js index de865822..fdeedeb7 100644 --- a/stats.js +++ b/stats.js @@ -118,7 +118,7 @@ class ServerStats { if (this.taskMap.size == 0) { return ""; } - let html = ''; + let html = '
'; html += ""; for (let m of this.taskMap.keys()) { let mm = this.taskMap.get(m); diff --git a/tx/cs/cs-api.js b/tx/cs/cs-api.js index ca5a2cbb..bc7a54f3 100644 --- a/tx/cs/cs-api.js +++ b/tx/cs/cs-api.js @@ -673,11 +673,13 @@ class CodeSystemProvider { /** * register the concept maps that are implicitly defined as part of the code system * + * @param {ConceptMap} map the map (this will have been returned from findImplicitConceptMap) * @param {Coding} coding the coding to translate - * @param {String} target - * @returns {CodeTranslation[]} the list of translations + * @param {String} target the target code system + * @param {boolean} reverse - if the translation is being run backwards + * @returns {CodeTranslation[]} the list of translations, each CodeTranslation has map, code, system, version, display, and relationship */ - async getTranslations(coding, target) { return null;} + async getTranslations(map, coding, target, reverse) { return null;} // ==== Parameter checking methods ========= _ensureLanguages(param) { @@ -853,7 +855,7 @@ class CodeSystemFactoryProvider { } /** - * see comemnts for registerSupplements() + * see comments for registerSupplements() * * @param {CodeSystem} supplement - the supplement to flesh out * @returns void @@ -865,6 +867,8 @@ class CodeSystemFactoryProvider { /** * build and return a known concept map from the URL, if there is one. * + * the conceptmap is never visible to a user; if it has an implicitSource, then + * provider.getTranslations will be called when it's actually used * @param url * @param version * @returns {ConceptMap} diff --git a/tx/cs/cs-omop.js b/tx/cs/cs-omop.js index 3349b1f5..5933852d 100644 --- a/tx/cs/cs-omop.js +++ b/tx/cs/cs-omop.js @@ -660,8 +660,10 @@ class OMOPServices extends BaseCSServices { } // Translation support - async getTranslations(coding, target) { - + async getTranslations(map, coding, target) { + if (map == null) { + return; + } const vocabId = getVocabId(target); if (vocabId === -1) { @@ -680,7 +682,7 @@ class OMOPServices extends BaseCSServices { reject(err); } else { const translations = rows.map(row => ({ - uri: target, + system: target, code: row.concept_code, display: row.concept_name, relationship: 'equivalent', diff --git a/tx/cs/cs-rxnorm.js b/tx/cs/cs-rxnorm.js index 2f034833..82badecd 100644 --- a/tx/cs/cs-rxnorm.js +++ b/tx/cs/cs-rxnorm.js @@ -395,24 +395,24 @@ class RxNormServices extends CodeSystemProvider { } else if (this.rels.includes(prop)) { if (value.startsWith('CUI:')) { const cui = value.substring(4); - sql = `AND (${this.getCodeField()} IN (SELECT ${this.getCodeField()} FROM rxnconso WHERE RXCUI IN (SELECT RXCUI1 FROM rxnrel WHERE REL = $rel AND RXCUI2 = $cui2)))`; + sql = `AND (${this.getCodeField()} IN (SELECT ${this.getCodeField()} FROM rxnconso WHERE RXCUI IN (SELECT RXCUI2 FROM rxnrel WHERE REL = $rel AND RXCUI1 = $cui2)))`; params.rel = this.#sqlWrapString(prop); params.cui2 = this.#sqlWrapString(cui); } else if (value.startsWith('AUI:')) { const aui = value.substring(4); - sql = `AND (${this.getCodeField()} IN (SELECT ${this.getCodeField()} FROM rxnconso WHERE RXAUI IN (SELECT RXAUI1 FROM rxnrel WHERE REL = $rel AND RXAUI2 = $aui2)))`; + sql = `AND (${this.getCodeField()} IN (SELECT ${this.getCodeField()} FROM rxnconso WHERE RXAUI IN (SELECT RXAUI2 FROM rxnrel WHERE REL = $rel AND RXAUI1 = $aui2)))`; params.rel = this.#sqlWrapString(prop); params.aui2 = this.#sqlWrapString(aui); } } else if (this.reltypes.includes(prop)) { if (value.startsWith('CUI:')) { const cui = value.substring(4); - sql = `AND (${this.getCodeField()} IN (SELECT ${this.getCodeField()} FROM rxnconso WHERE RXCUI IN (SELECT RXCUI1 FROM rxnrel WHERE RELA = $rela AND RXCUI2 = $cui2)))`; + sql = `AND (${this.getCodeField()} IN (SELECT ${this.getCodeField()} FROM rxnconso WHERE RXCUI IN (SELECT RXCUI2 FROM rxnrel WHERE RELA = $rela AND RXCUI1 = $cui2)))`; params.rela = this.#sqlWrapString(prop); params.cui2 = this.#sqlWrapString(cui); } else if (value.startsWith('AUI:')) { const aui = value.substring(4); - sql = `AND (${this.getCodeField()} IN (SELECT ${this.getCodeField()} FROM rxnconso WHERE RXAUI IN (SELECT RXAUI1 FROM rxnrel WHERE RELA = $rela AND RXAUI2 = $aui2)))`; + sql = `AND (${this.getCodeField()} IN (SELECT ${this.getCodeField()} FROM rxnconso WHERE RXAUI IN (SELECT RXAUI2 FROM rxnrel WHERE RELA = $rela AND RXAUI1 = $aui2)))`; params.rela = this.#sqlWrapString(prop); params.aui2 = this.#sqlWrapString(aui); } @@ -498,8 +498,6 @@ class RxNormServices extends CodeSystemProvider { } async filterSize(filterContext, set) { - - if (!set.executed) { await this.#executeFilter(set); } diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index 814e08b0..d4f9fe81 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -12,6 +12,7 @@ const { const {DesignationUse} = require("../library/designations"); const {BaseCSServices} = require("./cs-base"); const {formatDateMMDDYYYY} = require("../../library/utilities"); +const {ConceptMap} = require("../library/conceptmap"); // Context kinds matching Pascal enum const SnomedProviderContextKind = { @@ -1213,6 +1214,64 @@ class SnomedProvider extends BaseCSServices { (cd.use.code === '900000000000013009' || cd.use.code === '900000000000003001'); } + async getTranslations(map, coding, target, reverse) { + if (!map || (target && target !== this.system()) || reverse) { + return []; + } + let ref = this.sct.concepts.findConcept(map.id); + if (!ref.found) { + return []; + } + let rref = this.sct.refSetIndex.getRefSetByConcept(ref.index); + if (rref == -1) { + return []; + } + let refSetRecord = this.sct.refSetIndex.getReferenceSet(rref); + let members = this.sct.refSetMembers.getMembers(refSetRecord.membersByRef); + let srcConcept = this.sct.concepts.findConcept(coding.code); + if (!srcConcept.found) { + return []; + } + + let result = []; + let L = 0; + let H = members.length - 1; + while (L <= H) { + const I = Math.floor((L + H) / 2); + const ref = members[I].ref; + if (ref < srcConcept.index) { + L = I + 1; + } else if (ref > srcConcept.index) { + H = I - 1; + } else { + // Found — but scan left for first match in case of duplicates + let first = I; + while (first > 0 && members[first - 1].ref === srcConcept.index) { + first--; + } + // Process all matching members + for (let i = first; i < members.length && members[i].ref === srcConcept.index; i++) { + let values = this.sct.refs.getReferences(members[i].values); + if (values && values.length >= 1) { + let tgtId = String(this.sct.concepts.getConceptId(values[0])); + let ct = { + map: map.vurl, + code: tgtId, + system: this.system(), + version : this.version(), + display: await this.display(tgtId), + relationship: map.jsonObj.relationship + } + result.push(ct); + } + } + break; + } + } + + return result; + } + } /** @@ -1339,7 +1398,7 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { return null; } let rref = this.snomedServices.refSetIndex.getRefSetByConcept(ref.index); - if (rref == 0) { + if (rref == -1) { return null; } return { @@ -1448,6 +1507,59 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { return edition + ' ' + formatDateMMDDYYYY(match[2].substring(4, 6) + match[2].substring(6, 8) + match[2].substring(0, 4)); } + + async findImplicitConceptMap(url, version) { + if (version && (version !== this.version())) { + return null; + } + if (!url || !url.startsWith(this.system()+"?fhir_cm=")) { + return null; + } + let id = url.substring(url.indexOf("=")+1); + if (['900000000000523009', '900000000000526001', '900000000000527005', '900000000000530003'].includes(id)) { + let name = ''; + let relationship = ''; + switch (id) { + case '900000000000523009': + name = 'POSSIBLY EQUIVALENT TO'; + relationship = 'inexact'; + break; + case '900000000000526001': + name = 'REPLACED BY'; + relationship = 'equivalent'; + break; + case '900000000000527005': + name = 'SAME AS'; + relationship = 'equal'; + break; + case '900000000000530003': + name = 'ALTERNATIVE'; + relationship = 'inexact'; + break; + } + let cm = { + resourceType: 'ConceptMap', + internalSource : this, + relationship: relationship, + id : id, + url: `${this.system}?fhir_cm=${id}`, + version: this.version(), + name: `SNOMED CT ${name} Concept Map`, + description: `The concept map implicitly defined by the ${name} Association Reference Set`, + copyright: 'This value set includes content from SNOMED CT, which is copyright © 2002+ International Health Terminology Standards Development Organisation (SNOMED International), and distributed by agreement between SNOMED International and HL7', + status: 'active', + sourceUri: `${this.system}?fhir_vs`, + targetUri: `${this.system}?fhir_vs`, + group: [{ + source: 'http://snomed.info/sct', + target: 'http://snomed.info/sct' + }] + } + return new ConceptMap(cm); + } else { + return null; + } + } } function getEditionName(edition) { diff --git a/tx/library/conceptmap.js b/tx/library/conceptmap.js index 89e32f02..ae420235 100644 --- a/tx/library/conceptmap.js +++ b/tx/library/conceptmap.js @@ -148,6 +148,32 @@ class ConceptMap extends CanonicalResource { } return result; } + + listTranslationsReverse(coding, targetScope, sourceSystem) { + let result = []; + let vurl = VersionUtilities.vurl(coding.system, coding.version); + + let all = this.canonicalMatches(targetScope, this.targetScope); + for (const g of this.jsonObj.group || []) { + const targetOk = this.canonicalMatches(vurl, g.target); + const sourceOk = !sourceSystem || this.canonicalMatches(sourceSystem, g.source); + if (all || (sourceOk && targetOk)) { + for (const em of g.element || []) { + for (const tm of em.target || []) { + if (tm.code === coding.code) { + let match = { + group: g, + match: em, + target: tm + }; + result.push(match); + } + } + } + } + } + return result; + } /** * Gets the source scope (R5) or source system (R3/R4) * @returns {string|undefined} Source scope/system diff --git a/tx/library/extensions.js b/tx/library/extensions.js index 5f9b86fb..82d1385c 100644 --- a/tx/library/extensions.js +++ b/tx/library/extensions.js @@ -29,7 +29,7 @@ const Extensions = { } }, - checkNoModifiers(element, place, name) { + checkNoModifiers(element, place, name, resource) { if (!element) { return; } @@ -41,11 +41,12 @@ const Extensions = { for (const extension of element.modifierExtension) { urls.add(extension.url); } + const resId = resource ? resource : ""; const urlList = [...urls].join('\', \''); if (urls.size > 1) { - throw new Issue("error", "business-rule", null, null, 'Cannot process resource at "' + name + '" due to the presence of modifier extensions '+urlList); + throw new Issue("error", "business-rule", null, null, 'Cannot process resource '+resId+' at "' + name + '" due to the presence of modifier extensions '+urlList); } else { - throw new Issue("error", "business-rule", null, null, 'Cannot process resource at "' + name + '" due to the presence of the modifier extension '+urlList); + throw new Issue("error", "business-rule", null, null, 'Cannot process resource '+resId+' at "' + name + '" due to the presence of the modifier extension '+urlList); } } return true; diff --git a/tx/params.js b/tx/params.js index dbb1f5a8..1703b881 100644 --- a/tx/params.js +++ b/tx/params.js @@ -114,11 +114,11 @@ class TxParameters { break; } case 'force-valueset-version': { - this.seeVersionRule().push(getValuePrimitive(p), true, 'override'); + this.seeVersionRule(getValuePrimitive(p), true, 'override'); break; } case 'check-valueset-version': { - this.seeVersionRule().push(getValuePrimitive(p), true, 'check'); + this.seeVersionRule(getValuePrimitive(p), true, 'check'); break; } diff --git a/tx/provider.js b/tx/provider.js index b99c2811..532c0605 100644 --- a/tx/provider.js +++ b/tx/provider.js @@ -228,7 +228,10 @@ class Provider { for (let csp of this.codeSystemFactories.values()) { if (!uris.has(csp.system())) { uris.add(csp.system()); - await csp.findImplicitConceptMap(url, version); + let cm = await csp.findImplicitConceptMap(url, version); + if (cm) { + return cm; + } } } } diff --git a/tx/sct/structures.js b/tx/sct/structures.js index 2266bdc4..7024affd 100644 --- a/tx/sct/structures.js +++ b/tx/sct/structures.js @@ -1177,7 +1177,7 @@ class SnomedReferenceSetIndex { } } - return 0; // Not found + return -1; // Not found } count() { diff --git a/tx/workers/expand.js b/tx/workers/expand.js index 1d206553..5bf827e7 100644 --- a/tx/workers/expand.js +++ b/tx/workers/expand.js @@ -604,7 +604,7 @@ class ValueSetExpander { async checkSource(cset, exp, filter, srcURL, ts, vsInfo) { this.worker.deadCheck('checkSource'); - Extensions.checkNoModifiers(cset, 'ValueSetExpander.checkSource', 'set'); + Extensions.checkNoModifiers(cset, 'ValueSetExpander.checkSource', 'set', srcURL); let imp = false; for (const u of cset.valueSet || []) { this.worker.deadCheck('checkSource'); @@ -682,7 +682,7 @@ class ValueSetExpander { this.worker.deadCheck('processCodes#1'); const valueSets = []; - Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set'); + Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set', vsSrc.vurl); if (cset.valueSet || cset.concept || (cset.filter || []).length > 1) { this.canBeHierarchy = false; @@ -792,7 +792,7 @@ class ValueSetExpander { for (const cc of cset.concept) { this.worker.deadCheck('processCodes#3'); cds.clear(); - Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference'); + Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference', vsSrc.vurl); const cctxt = await cs.locate(cc.code, this.allAltCodes); if (cctxt && cctxt.context && (!this.params.activeOnly || !await cs.isInactive(cctxt.context)) && await this.passesFilters(cs, cctxt.context, prep, filters, 0)) { await this.listDisplaysFromProvider(cds, cs, cctxt.context); @@ -834,7 +834,7 @@ class ValueSetExpander { if (!fc.value) { throw new Issue('error', 'invalid', path + ".filter[" + i + "]", 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.httpLanguages, [cs.system(), fc.property, fc.op]), 'vs-invalid', 400); } - Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter'); + Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl); await cs.filter(prep, fc.property, fc.op, fc.value); } @@ -891,7 +891,7 @@ class ValueSetExpander { this.worker.deadCheck('processCodes#1'); const valueSets = []; - Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set'); + Extensions.checkNoModifiers(cset, 'ValueSetExpander.processCodes', 'set', vsSrc.vurl); if (cset.valueSet || cset.concept || (cset.filter || []).length > 1) { this.canBeHierarchy = false; @@ -978,7 +978,7 @@ class ValueSetExpander { for (const cc of cset.concept) { this.worker.deadCheck('processCodes#3'); cds.clear(); - Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference'); + Extensions.checkNoModifiers(cc, 'ValueSetExpander.processCodes', 'set concept reference', vsSrc.vurl); const cctxt = await cs.locate(cc.code, this.allAltCodes); if (cctxt && cctxt.context && (!this.params.activeOnly || !await cs.isInactive(cctxt)) && await this.passesFilters(cs, cctxt, prep, filters, 0)) { if (filter.passesDesignations(cds) || filter.passes(cc.code)) { @@ -1007,7 +1007,7 @@ class ValueSetExpander { for (let fc of cset.filter) { this.worker.deadCheck('processCodes#4a'); - Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter'); + Extensions.checkNoModifiers(fc, 'ValueSetExpander.processCodes', 'filter', vsSrc.vurl); await cs.filter(prep, fc.property, fc.op, fc.value); } @@ -1157,8 +1157,8 @@ class ValueSetExpander { this.totalStatus = 'uninitialised'; this.total = 0; - Extensions.checkNoImplicitRules(source,'ValueSetExpander.Expand', 'ValueSet'); - Extensions.checkNoModifiers(source,'ValueSetExpander.Expand', 'ValueSet'); + Extensions.checkNoImplicitRules(source,'ValueSetExpander.Expand', 'ValueSet', source.vurl); + Extensions.checkNoModifiers(source,'ValueSetExpander.Expand', 'ValueSet', source.vurl); this.worker.seeValueSet(source, this.params); this.valueSet = source; @@ -1272,7 +1272,7 @@ class ValueSetExpander { let vsInfo = this.scanValueSet(source.jsonObj.compose); try { - if (source.jsonObj.compose && Extensions.checkNoModifiers(source.jsonObj.compose, 'ValueSetExpander.Expand', 'compose') + if (source.jsonObj.compose && Extensions.checkNoModifiers(source.jsonObj.compose, 'ValueSetExpander.Expand', 'compose', source.vurl) && this.worker.checkNoLockedDate(source.url, source.jsonObj.compose)) { await this.handleCompose(source, filter, exp, notClosed, vsInfo); } diff --git a/tx/workers/related.js b/tx/workers/related.js index 01abddd8..55b5e382 100644 --- a/tx/workers/related.js +++ b/tx/workers/related.js @@ -205,12 +205,12 @@ class RelatedWorker extends TerminologyWorker { if (!thisC) { return this.makeOutcome("indeterminate", `The ValueSet ${thisVS.vurl} has no compose`); } - Extensions.checkNoModifiers(thisC, 'RelatedWorker.doRelated', 'compose') + Extensions.checkNoModifiers(thisC, 'RelatedWorker.doRelated', 'compose', thisVS.vurl) this.checkNoLockedDate(thisVS.vurl, thisC); if (!otherC) { return this.makeOutcome("indeterminate", `The ValueSet ${otherVS.vurl} has no compose`); } - Extensions.checkNoModifiers(otherC, 'RelatedWorker.doRelated', 'compose') + Extensions.checkNoModifiers(otherC, 'RelatedWorker.doRelated', 'compose', otherVS.vurl) this.checkNoLockedDate(otherVS.vurl, otherC); let systems = new Map(); // tracks whether they are version dependent or not diff --git a/tx/workers/translate.js b/tx/workers/translate.js index 9b839889..cd814453 100644 --- a/tx/workers/translate.js +++ b/tx/workers/translate.js @@ -113,6 +113,7 @@ class TranslateWorker extends TerminologyWorker { let targetScope = null; let sourceScope = null; let targetSystem = null; + let reverse = false; // Get the source coding // Accept both R5 names (sourceCoding, sourceCodeableConcept, sourceCode/sourceSystem) @@ -145,10 +146,32 @@ class TranslateWorker extends TerminologyWorker { 'system parameter is required when using code/sourceCode', null, 400); } const version = params.has('sourceVersion') ? params.get('sourceVersion') : params.get('version'); + coding = {system, version, code}; + } else if (params.has('targetCoding')) { + reverse = true; + coding = params.get('targetCoding'); + } else if (params.has('targetCodeableConcept')) { + reverse = true; + const cc = params.get('targetCodeableConcept'); + if (cc.coding && cc.coding.length > 0) { + coding = cc.coding[0]; // Use first coding + } else { + throw new Issue('error', 'invalid', null, null, + 'sourceCodeableConcept must contain at least one coding', null, 400); + } + } else if (params.has('targetCode')) { + reverse = true; + const code = params.get('targetCode'); + const system = params.get('targetSystem'); + if (!system) { + throw new Issue('error', 'invalid', null, null, + 'targetSystem parameter is required when using targetCode', null, 400); + } + const version = params.get('targetVersion'); coding = { system, version, code }; } else { throw new Issue('error', 'invalid', null, null, - 'Must provide sourceCode (with system), sourceCoding, or sourceCodeableConcept', null, 400); + 'Must provide sourceCode+(source)system, sourceCoding, or sourceCodeableConcept, or targetCode+targetSystem), targetCoding, or targetCodeableConcept', null, 400); } // Get the concept map @@ -173,21 +196,33 @@ class TranslateWorker extends TerminologyWorker { if (params.has('targetScope')) { targetScope = params.get('targetScope'); } - if (params.has('targetSystem')) { - targetSystem = params.get('targetSystem'); + if (reverse) { + if (params.has('sourceSystem')) { + targetSystem = params.get('sourceSystem'); + } + } else { + if (params.has('targetSystem')) { + targetSystem = params.get('targetSystem'); + } } - + let explicit = true; // If no explicit concept map, we need to find one based on source/target if (conceptMaps.length == 0) { - await this.findConceptMapsInAdditionalResources(conceptMaps, coding.system, sourceScope, targetScope, targetSystem); - await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, sourceScope, targetScope, targetSystem, coding.code); + explicit = false; + if (reverse) { + await this.findConceptMapsInAdditionalResources(conceptMaps,targetSystem, targetScope, sourceScope, coding.system); + await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, targetSystem, targetScope, sourceScope, coding.system, coding.code); + } else { + await this.findConceptMapsInAdditionalResources(conceptMaps, coding.system, sourceScope, targetScope, targetSystem); + await this.provider.findConceptMapForTranslation(this.opContext, conceptMaps, coding.system, sourceScope, targetScope, targetSystem, coding.code); + } if (conceptMaps.length == 0) { throw new Issue('error', 'not-found', null, null, 'No suitable ConceptMaps found for the specified source and target', null, 404); } } // Perform the translation - const result = await this.doTranslate(conceptMaps, coding, targetScope, targetSystem, txp); + const result = await this.doTranslate(conceptMaps, coding, targetScope, targetSystem, txp, reverse, explicit); return res.status(200).json(result); } @@ -287,7 +322,7 @@ class TranslateWorker extends TerminologyWorker { return result; } - translateUsingGroups(cm, coding, targetScope, targetSystem, params, output) { + translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, output, explicit) { let result = false; const matches = cm.listTranslations(coding, targetScope, targetSystem); if (matches.length > 0) { @@ -295,7 +330,13 @@ class TranslateWorker extends TerminologyWorker { const g = match.group; const em = match.match; for (const map of em.target || []) { - if (['null', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact'].includes(map.relationship)) { + let ok = false; + if (map.equivalence) { // R4 mode + ok = ['null', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact'].includes(map.equivalence); + } else { + ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target'].includes(map.relationship); + } + if (ok) { result = true; const outcome = { @@ -303,22 +344,119 @@ class TranslateWorker extends TerminologyWorker { code: map.code }; + if (!this.hasMatch(output, outcome)) { + const matchParts = []; + matchParts.push({ + name: 'concept', + valueCoding: outcome + }); + matchParts.push({ + name: 'relationship', + valueCode: map.relationship + }); + // equivalence vs relationship will be sorted out in the version transform for parameters + if (map.equivalence) { + matchParts.push({ + name: 'equivalence', + valueCode: map.equivalence + }); + } + if (map.comment) { + matchParts.push({ + name: 'message', + valueString: map.comment + }); + } + for (const prod of map.product || []) { + const productParts = []; + productParts.push({ + name: 'element', + valueString: prod.property + }); + productParts.push({ + name: 'concept', + valueCoding: { + system: prod.system, + code: prod.value + } + }); + matchParts.push({ + name: 'product', + part: productParts + }); + } + if (!explicit) { + matchParts.push({ + name: 'sourceMap', + valueCanonical: cm.vurl + }); + } + output.push({ + name: 'match', + part: matchParts + }); + } + } + } + } + } + return result; + } + + translateUsingGroupsReverse(cm, coding, targetScope, targetSystem, params, output, explicit) { + let result = false; + const matches = cm.listTranslationsReverse(coding, targetScope, targetSystem); + if (matches.length > 0) { + for (let match of matches) { + const g = match.group; + const em = match.match; + const map = match.target; + let ok = false; + if (map.equivalence) { // R4 mode + ok = ['null', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower', 'specializes', 'inexact'].includes(map.equivalence); + } else { + ok = ['null', 'related-to', 'equivalent', 'source-is-narrower-than-target', 'source-is-broader-than-target'].includes(map.relationship); + } + if (ok) { + result = true; + + const outcome = { + system: g.source, + code: em.code + }; + const t = { + system: g.target, + code: coding.code + }; + + if (!this.hasMatch(output, outcome)) { const matchParts = []; matchParts.push({ - name: 'concept', + name: 'source', valueCoding: outcome }); + matchParts.push({ + name: 'concept', + valueCoding: t + }); matchParts.push({ name: 'relationship', valueCode: map.relationship }); - if (map.comments) { + // equivalence vs relationship will be sorted out in the version transform for parameters + if (map.equivalence) { + matchParts.push({ + name: 'equivalence', + valueCode: map.equivalence + }); + } + if (map.comment) { matchParts.push({ name: 'message', - valueString: map.comments + valueString: map.comment }); } - for (const prod of map.products || []) { + for (const prod of map.product || []) { const productParts = []; productParts.push({ name: 'element', @@ -336,6 +474,12 @@ class TranslateWorker extends TerminologyWorker { part: productParts }); } + if (!explicit) { + matchParts.push({ + name: 'sourceMap', + valueCanonical: cm.vurl + }); + } output.push({ name: 'match', part: matchParts @@ -347,7 +491,7 @@ class TranslateWorker extends TerminologyWorker { return result; } - async translateUsingCodeSystem(cm, coding, target, params, output) { + async translateUsingCodeSystem(cm, coding, target, params, output, reverse, explicit) { let result = false; const factory = cm.jsonObj.internalSource; let prov = await factory.build(this.opContext, []); @@ -357,7 +501,7 @@ class TranslateWorker extends TerminologyWorker { valueUri: prov.system() + '|' + prov.version() }); - let translations = await prov.getTranslations(coding, target); + let translations = await prov.getTranslations(cm, coding, target, reverse); if (translations.length > 0) { result = true; @@ -371,7 +515,7 @@ class TranslateWorker extends TerminologyWorker { } const outcome = { - system: t.uri, + system: t.system, code: t.code, version: t.version, display: t.display @@ -392,6 +536,12 @@ class TranslateWorker extends TerminologyWorker { valueString: t.message }); } + if (!explicit) { + matchParts.push({ + name: 'sourceMap', + valueCanonical: cm.vurl + }); + } output.push({ name: 'match', part: matchParts @@ -408,9 +558,11 @@ class TranslateWorker extends TerminologyWorker { * @param {string} targetScope - Target value set scope (optional) * @param {string} targetSystem - Target code system (optional) * @param {Parameters} params - Full parameters object + * @param {boolean} reverse - Full parameters object* + * @param {boolean} explicit - If the concept map was named explicitly * @returns {Object} Parameters resource with translate result */ - async doTranslate(conceptMaps, coding, targetScope, targetSystem, params) { + async doTranslate(conceptMaps, coding, targetScope, targetSystem, params, reverse, explicit) { this.deadCheck('doTranslate'); const result = []; @@ -419,9 +571,11 @@ class TranslateWorker extends TerminologyWorker { let added = false; for (const cm of conceptMaps) { if (cm.jsonObj.internalSource) { - added = await this.translateUsingCodeSystem(cm, coding, targetSystem, params, result) || added; - } else { - added = this.translateUsingGroups(cm, coding, targetScope, targetSystem, params, result) || added; + added = await this.translateUsingCodeSystem(cm, coding, targetSystem, params, result, reverse, explicit) || added; + } else if (reverse) { + added = this.translateUsingGroupsReverse(cm, coding, targetScope, targetSystem, params, result, reverse, explicit) || added; + } else{ + added = this.translateUsingGroupsForwards(cm, coding, targetScope, targetSystem, params, result, reverse, explicit) || added; } } result.push({ @@ -517,6 +671,16 @@ class TranslateWorker extends TerminologyWorker { } } } + + hasMatch(output, outcome) { + for (let o of output) { + let c = o.part.find(x => x.name === 'concept'); + if (c.valueCoding.code === outcome.code && c.valueCoding.system === outcome.system) { + return true; + } + } + return false; + } } module.exports = TranslateWorker; \ No newline at end of file diff --git a/tx/workers/validate.js b/tx/workers/validate.js index fd2122d6..74b4818b 100644 --- a/tx/workers/validate.js +++ b/tx/workers/validate.js @@ -316,21 +316,21 @@ class ValueSetChecker { } Extensions.checkNoImplicitRules(this.valueSet, 'ValueSetChecker.prepare', 'ValueSet'); - Extensions.checkNoModifiers(this.valueSet, 'ValueSetChecker.prepare', 'ValueSet'); + Extensions.checkNoModifiers(this.valueSet, 'ValueSetChecker.prepare', 'ValueSet', this.valueSet.vurl); this.allValueSet = this.valueSet.url === 'http://hl7.org/fhir/ValueSet/@all'; if (this.valueSet.jsonObj.compose) { - Extensions.checkNoModifiers(this.valueSet.jsonObj.compose, 'ValueSetChecker.prepare', 'ValueSet.compose'); - this.worker.checkNoLockedDate(this.valueSet.url, this.valueSet.jsonObj.compose) + Extensions.checkNoModifiers(this.valueSet.jsonObj.compose, 'ValueSetChecker.prepare', 'ValueSet.compose', this.valueSet.vurl); + this.worker.checkNoLockedDate(this.valueSet.vurl, this.valueSet.jsonObj.compose) let i = 0; for (let cc of this.valueSet.jsonObj.compose.include || []) { - await this.prepareConceptSet('include['+i+']', cc); + await this.prepareConceptSet('include['+i+']', cc, this.valueSet); i++; } i = 0; for (let cc of this.valueSet.jsonObj.compose.exclude || []) { - await this.prepareConceptSet('exclude['+i+']', cc); + await this.prepareConceptSet('exclude['+i+']', cc, this.valueSet); i++; } } @@ -342,9 +342,9 @@ class ValueSetChecker { } } - async prepareConceptSet(desc, cc) { + async prepareConceptSet(desc, cc, vs) { this.worker.deadCheck('prepareConceptSet'); - Extensions.checkNoModifiers(cc, 'ValueSetChecker.prepare', desc); + Extensions.checkNoModifiers(cc, 'ValueSetChecker.prepare', desc, vs.vurl); this.worker.opContext.addNote(this.valueSet, 'Prepare ' + desc + ': "' + this.worker.renderer.displayValueSetInclude(cc) + '"', this.indentCount); if (cc.valueSet) { for (let u of cc.valueSet) { @@ -374,7 +374,7 @@ class ValueSetChecker { let i = 0; for (let ccf of cc.filter || []) { this.worker.deadCheck('prepareConceptSet#2'); - Extensions.checkNoModifiers(ccf, 'ValueSetChecker.prepare', desc + '.filter'); + Extensions.checkNoModifiers(ccf, 'ValueSetChecker.prepare', desc + '.filter', this.valueSet.vurl); if (!ccf.value) { throw new Issue('error', 'invalid', "ValueSet.compose."+desc+".filter["+i+"]", 'UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.worker.i18n.translate('UNABLE_TO_HANDLE_SYSTEM_FILTER_WITH_NO_VALUE', this.params.HTTPLanguages, [cs.system(), ccf.property, ccf.op]), "vs-invalid").handleAsOO(400); @@ -677,7 +677,7 @@ class ValueSetChecker { throw new Issue('error', 'not-found', null, 'VALUESET_SUPPLEMENT_MISSING', this.worker.i18n.translatePlural(unused.size, 'VALUESET_SUPPLEMENT_MISSING', this.params.HTTPLanguages, [[...unused].join(',')]), 'not-found').handleAsOO(422); } - if (Extensions.checkNoModifiers(this.valueSet.jsonObj.compose, 'ValueSetChecker.prepare', 'ValueSet.compose')) { + if (Extensions.checkNoModifiers(this.valueSet.jsonObj.compose, 'ValueSetChecker.prepare', 'ValueSet.compose', this.valueSet.vurl)) { result = false; let determinedVersion = undefined; if (!version) { @@ -807,7 +807,7 @@ class ValueSetChecker { } } } - } else if (Extensions.checkNoModifiers(this.valueSet.jsonObj.expansion, 'ValueSetChecker.prepare', 'ValueSet.expansion')) { + } else if (Extensions.checkNoModifiers(this.valueSet.jsonObj.expansion, 'ValueSetChecker.prepare', 'ValueSet.expansion', this.valueSet.vurl)) { let ccc = this.valueSet.findContains(system, version, code); if (ccc === null) { result = false; diff --git a/tx/xversion/xv-parameters.js b/tx/xversion/xv-parameters.js index 660bdc26..08e79684 100644 --- a/tx/xversion/xv-parameters.js +++ b/tx/xversion/xv-parameters.js @@ -10,7 +10,11 @@ const {VersionUtilities} = require("../../library/version-utilities"); function parametersToR5(jsonObj, sourceVersion) { if (VersionUtilities.isR5Ver(sourceVersion)) { - return jsonObj; // No conversion needed + if (jsonObj.parameter && jsonObj.parameter.find(p => p.name == 'match')) { + return convertResourceWithinR5(JSON.parse(JSON.stringify(jsonObj))); + } else { + return jsonObj; // No conversion needed + } } const {convertResourceFromR5} = require("./xv-resource"); @@ -59,8 +63,57 @@ function parametersR5ToR4(r5Obj) { if (p.resource) { p.resource = convertResourceFromR5(p.resource, "R4"); } + if (p.name == 'match') { + fixMatchParameterfor4(p); + } + } + return r5Obj; +} + +function convertResourceWithinR5(r5Obj) { + for (let p of r5Obj.parameter) { + if (p.name == 'match') { + fixMatchParameterfor5(p); + } } return r5Obj; + +} + +function fixMatchParameterfor5(p) { + if (p.part) { + p.part = p.part.filter(pp => pp.name !== 'equivalence'); + } +} + +function fixMatchParameterfor4(p) { + if (p.part) { + if (!p.part.find(pp => pp.name === 'equivalence')) { + let rel = p.part.find(pp => pp.name === 'relationship'); + if (rel && rel.valueCode) { + let pp = {name: "equivalence"}; + switch (rel.valueCode) { + case 'related-to': + pp.valueCode = 'relatedto'; + break; + case 'equivalent': + pp.valueCode = 'equivalent'; + break; + case 'source-is-narrower-than-target': + pp.valueCode = 'wider'; + break; + case 'source-is-broader-than-target': + pp.valueCode = 'narrower'; + break; + case 'not-related-to': + pp.valueCode = 'unmatched'; + break; + } + p.part.push(pp); + } + } + p.part = p.part.filter(pp => pp.name !== 'relationship'); + } } function convertParameterR5ToR3(p) {
Background TaskStatusFrequencyLast Seen