Repository: FHIR/sushi
Pull request #554 (2020-07-29 14:41:33)

Fix custom extension urls
If you override an extension's URL with a caret rule, the extension definition should fix the 'Extension.url' element value to the overridden URL. Prior to this fix, it fixed it to the canonical-based URL instead.  This caused problems if you tried to access and use that extension in a profile.

To test, you can run the test from commit f635fff without the fix.  It should fail.  If you run it with the fix (commit 43671f8) it should succeed.

You can also test it using the example FSH for #532:
```
Extension: TestExtension
* ^url = "http://foo.bar.baz"

Profile: TestProfile
Parent: Communication
* payload.extension contains TestExtension named ContentConcept 1..
// * payload.extension contains http://foo.bar.baz named ContentConcept 1..
* payload.extension[ContentConcept].value[x] only CodeableConcept
```

Fixs #532 

cmoesel: Oh yeah.  I almost forgot.  This also fixes fishing by URL when the URL is overridden via a caret rule.

Patch:
diff --git a/src/export/StructureDefinitionExporter.ts b/src/export/StructureDefinitionExporter.ts
index 8602f11..a90bf60 100644
--- a/src/export/StructureDefinitionExporter.ts
+++ b/src/export/StructureDefinitionExporter.ts
@@ -30,7 +30,8 @@ import {
   splitOnPathPeriods,
   applyMixinRules,
   cleanResource,
-  applyInsertRules
+  applyInsertRules,
+  getUrlFromFshDefinition
 } from '../fhirtypes/common';
 import { Package } from './Package';
 
@@ -89,7 +90,7 @@ export class StructureDefinitionExporter implements Fishable {
       // for consistency, delete rather than leaving null-valued
       delete structDef.modifierExtension;
     }
-    structDef.url = `${this.tank.config.canonical}/StructureDefinition/${structDef.id}`;
+    structDef.url = getUrlFromFshDefinition(fshDefinition, this.tank.config.canonical);
     delete structDef.identifier;
     structDef.version = this.tank.config.version; // can be overridden using a rule
     structDef.setName(fshDefinition.name, fshDefinition.sourceInfo);
diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts
index 31814da..bde3213 100644
--- a/src/fhirtypes/common.ts
+++ b/src/fhirtypes/common.ts
@@ -552,6 +552,36 @@ export function isInheritedResource(
   }
 }
 
+/**
+ * Determines the formal FHIR URL to use to refer to this entity (for example when fishing).
+ * If a caret value rule has been applied to the entity's url, use the value specified in that
+ * rule. Otherwise, use the default url based on the configured canonical url.
+ *
+ * @param fshDefinition - The FSH definition that the returned URL refers to
+ * @param canonical - The canonical URL for the FSH project
+ * @returns {string} - The URL to use to refer to the FHIR entity
+ */
+export function getUrlFromFshDefinition(
+  fshDefinition: Profile | Extension | FshValueSet | FshCodeSystem,
+  canonical: string
+): string {
+  for (const rule of fshDefinition.rules) {
+    if (rule instanceof CaretValueRule && rule.path === '' && rule.caretPath === 'url') {
+      // this value should only be a string, but that might change at some point
+      return rule.value.toString();
+    }
+  }
+  let fhirType: string;
+  if (fshDefinition instanceof FshValueSet) {
+    fhirType = 'ValueSet';
+  } else if (fshDefinition instanceof FshCodeSystem) {
+    fhirType = 'CodeSystem';
+  } else {
+    fhirType = 'StructureDefinition';
+  }
+  return `${canonical}/${fhirType}/${fshDefinition.id}`;
+}
+
 const nameRegex = /^[A-Z]([A-Za-z0-9_]){0,254}$/;
 
 export class HasName {
diff --git a/src/import/FSHTank.ts b/src/import/FSHTank.ts
index 1521e2c..bc323d8 100644
--- a/src/import/FSHTank.ts
+++ b/src/import/FSHTank.ts
@@ -12,7 +12,7 @@ import {
 } from '../fshtypes';
 import flatMap from 'lodash/flatMap';
 import { Type, Metadata, Fishable } from '../utils/Fishable';
-import { CaretValueRule } from '../fshtypes/rules';
+import { getUrlFromFshDefinition } from '../fhirtypes/common';
 
 export class FSHTank implements Fishable {
   constructor(public readonly docs: FSHDocument[], public readonly config: Configuration) {}
@@ -140,7 +140,7 @@ export class FSHTank implements Fishable {
             p =>
               p.name === item ||
               p.id === item ||
-              `${this.config.canonical}/StructureDefinition/${p.id}` === item
+              getUrlFromFshDefinition(p, this.config.canonical) === item
           );
           break;
         case Type.Extension:
@@ -148,7 +148,7 @@ export class FSHTank implements Fishable {
             e =>
               e.name === item ||
               e.id === item ||
-              `${this.config.canonical}/StructureDefinition/${e.id}` === item
+              getUrlFromFshDefinition(e, this.config.canonical) === item
           );
           break;
         case Type.ValueSet:
@@ -156,15 +156,15 @@ export class FSHTank implements Fishable {
             vs =>
               vs.name === item ||
               vs.id === item ||
-              `${this.config.canonical}/ValueSet/${vs.id}` === item
+              getUrlFromFshDefinition(vs, this.config.canonical) === item
           );
           break;
         case Type.CodeSystem:
           result = this.getAllCodeSystems().find(
-            vs =>
-              vs.name === item ||
-              vs.id === item ||
-              `${this.config.canonical}/CodeSystem/${vs.id}` === item
+            cs =>
+              cs.name === item ||
+              cs.id === item ||
+              getUrlFromFshDefinition(cs, this.config.canonical) === item
           );
           break;
         case Type.Instance:
@@ -201,41 +201,16 @@ export class FSHTank implements Fishable {
         name: result.name
       };
       if (result instanceof Profile || result instanceof Extension) {
-        meta.url = this.getMetadataUrl(result, 'StructureDefinition');
+        meta.url = getUrlFromFshDefinition(result, this.config.canonical);
         meta.parent = result.parent;
-      } else if (result instanceof FshValueSet) {
-        meta.url = this.getMetadataUrl(result, 'ValueSet');
-      } else if (result instanceof FshCodeSystem) {
-        meta.url = this.getMetadataUrl(result, 'CodeSystem');
+      } else if (result instanceof FshValueSet || result instanceof FshCodeSystem) {
+        meta.url = getUrlFromFshDefinition(result, this.config.canonical);
       }
       return meta;
     }
     return;
   }
 
-  /**
-   * Determines the URL to use to refer to a fished-up FHIR entity.
-   * If a caret value rule has been applied to the entity's url, use the
-   * value specified in that rule.
-   * Otherwise, use the default url based on the configured canonical url.
-   *
-   * @param {Profile | Extension | FshValueSet | FshCodeSystem} result - The FHIR entity that was fished up
-   * @param {string} fhirType - The entity's type as a string for building the default URL
-   * @returns {string} - The URL to use to refer to the FHIR entity
-   */
-  getMetadataUrl(
-    result: Profile | Extension | FshValueSet | FshCodeSystem,
-    fhirType: string
-  ): string {
-    for (const rule of result.rules) {
-      if (rule instanceof CaretValueRule && rule.path === '' && rule.caretPath === 'url') {
-        // this value should only be a string, but that might change at some point
-        return rule.value.toString();
-      }
-    }
-    return `${this.config.canonical}/${fhirType}/${result.id}`;
-  }
-
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   fishForFHIR(item: string, ...types: Type[]): any | undefined {
     // the FSHTank cannot return FHIR definitions, but we define this function


Test patch:
diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts
index 5b91c2f..8af1487 100644
--- a/test/export/StructureDefinitionExporter.test.ts
+++ b/test/export/StructureDefinitionExporter.test.ts
@@ -1993,6 +1993,92 @@ describe('StructureDefinitionExporter', () => {
     expect(extensionSliceValueX.type).toEqual([new ElementDefinitionType('Quantity')]);
   });
 
+  it('should apply a ContainsRule of an extension with an overridden URL', () => {
+    const profile = new Profile('Foo');
+    profile.parent = 'Observation';
+    doc.profiles.set('Foo', profile);
+
+    const extBar = new Extension('Bar');
+    const caretValueRule = new CaretValueRule('');
+    caretValueRule.caretPath = 'url';
+    caretValueRule.value = 'http://different-url.com/StructureDefinition/Bar';
+    extBar.rules.push(caretValueRule);
+    doc.extensions.set('Bar', extBar);
+
+    const constainsRule = new ContainsRule('extension');
+    constainsRule.items = [{ name: 'bar', type: 'Bar' }];
+    profile.rules.push(constainsRule);
+
+    const onlyRule = new OnlyRule('extension[bar].value[x]');
+    onlyRule.types = [{ type: 'Quantity' }];
+    profile.rules.push(onlyRule);
+
+    exporter.exportStructDef(profile);
+    const sd = pkg.profiles[0];
+
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+
+    const extension = sd.elements.find(e => e.id === 'Observation.extension');
+    const extensionSlice = sd.elements.find(e => e.id === 'Observation.extension:bar');
+    const extensionSliceUrl = sd.elements.find(e => e.id === 'Observation.extension:bar.url');
+    const extensionSliceValueX = sd.elements.find(
+      e => e.id === 'Observation.extension:bar.value[x]'
+    );
+
+    expect(extension.slicing).toBeDefined();
+    expect(extension.slicing.discriminator.length).toBe(1);
+    expect(extension.slicing.discriminator[0]).toEqual({ type: 'value', path: 'url' });
+    expect(extensionSlice).toBeDefined();
+    expect(extensionSliceUrl).toBeDefined();
+    expect(extensionSliceUrl.fixedUri).toBe('http://different-url.com/StructureDefinition/Bar');
+    expect(extensionSliceValueX).toBeDefined();
+    expect(extensionSliceValueX.type).toEqual([new ElementDefinitionType('Quantity')]);
+  });
+
+  it('should apply a ContainsRule of an extension with an overridden URL by URL', () => {
+    const profile = new Profile('Foo');
+    profile.parent = 'Observation';
+    doc.profiles.set('Foo', profile);
+
+    const extBar = new Extension('Bar');
+    const caretValueRule = new CaretValueRule('');
+    caretValueRule.caretPath = 'url';
+    caretValueRule.value = 'http://different-url.com/StructureDefinition/Bar';
+    extBar.rules.push(caretValueRule);
+    doc.extensions.set('Bar', extBar);
+
+    const constainsRule = new ContainsRule('extension');
+    constainsRule.items = [{ name: 'bar', type: 'Bar' }];
+    profile.rules.push(constainsRule);
+
+    const onlyRule = new OnlyRule(
+      'extension[http://different-url.com/StructureDefinition/Bar].value[x]'
+    );
+    onlyRule.types = [{ type: 'Quantity' }];
+    profile.rules.push(onlyRule);
+
+    exporter.exportStructDef(profile);
+    const sd = pkg.profiles[0];
+
+    expect(loggerSpy.getAllLogs('error')).toHaveLength(0);
+
+    const extension = sd.elements.find(e => e.id === 'Observation.extension');
+    const extensionSlice = sd.elements.find(e => e.id === 'Observation.extension:bar');
+    const extensionSliceUrl = sd.elements.find(e => e.id === 'Observation.extension:bar.url');
+    const extensionSliceValueX = sd.elements.find(
+      e => e.id === 'Observation.extension:bar.value[x]'
+    );
+
+    expect(extension.slicing).toBeDefined();
+    expect(extension.slicing.discriminator.length).toBe(1);
+    expect(extension.slicing.discriminator[0]).toEqual({ type: 'value', path: 'url' });
+    expect(extensionSlice).toBeDefined();
+    expect(extensionSliceUrl).toBeDefined();
+    expect(extensionSliceUrl.fixedUri).toBe('http://different-url.com/StructureDefinition/Bar');
+    expect(extensionSliceValueX).toBeDefined();
+    expect(extensionSliceValueX.type).toEqual([new ElementDefinitionType('Quantity')]);
+  });
+
   it('should apply multiple ContainsRule on an element with defined slicing', () => {
     const profile = new Profile('Foo');
     profile.parent = 'resprate';