Repository: FHIR/sushi
Pull request #506 (2020-06-26 17:28:02)

Support complex dependencies in config
Support a way to specify a dependency's id and/or url in addition to its version for advanced use cases.

Fixes: #497

To manually test this, add the following dependency to your FSH Tank's `config.yaml`:

```yaml
dependencies:
  de.basisprofil.r4: 0.9.7
```

On `master` (or the release version of SUSHI), you'll see this message:
> error Failed to add de.basisprofil.r4:0.9.7 to ImplementationGuide instance.  Could not determine its canonical URL from the FHIR cache.

On this PR branch, you'll see a more helpful message:
> error Failed to add de.basisprofil.r4:0.9.7 to ImplementationGuide instance because SUSHI could not find the IG URL in the dependency IG. To specify the IG URL in your config.yaml, use the dependency details format:
> 
> ```
> dependencies:
>   de.basisprofil.r4:
>     uri: http://my-fhir-ig.org/ImplementationGuide/123
>     version: 0.9.7
> ```

Then update your config to specify a URL:

```yaml
dependencies:
  de.basisprofil.r4:
    uri: https://simplifier.net/guide/basisprofil-de-r4
    version: 0.9.7
```

Now when you run it on this PR branch, it should succeed.

Last, to test one more error condition that can now arise, _remove_ the version from the complex dependency details:

```yaml
dependencies:
  de.basisprofil.r4:
    uri: https://simplifier.net/guide/basisprofil-de-r4
```

Run it again on this PR branch and now you'll get a detailed error during package loading:
> error Failed to load de.basisprofil.r4: No version specified. To specify the version in your config.yaml, either use the simple dependency format:
> 
> ```
> dependencies:
>   de.basisprofil.r4: current
> ```
> 
> or use the detailed dependency format to specify other properties as well:
> 
> ```
> dependencies:
>   de.basisprofil.r4:
>     uri: https://simplifier.net/guide/basisprofil-de-r4
>     version: current
> ```

As well as a less detailed error in the IG export:
> error Failed to add de.basisprofil.r4 to ImplementationGuide instance because no version was specified in your config.yaml.

cmoesel: @ngfreiter -- your feedback has been addressed in 73b1925.

Patch:
diff --git a/src/ig/IGExporter.ts b/src/ig/IGExporter.ts
index ca95f8e..04afe8f 100644
--- a/src/ig/IGExporter.ts
+++ b/src/ig/IGExporter.ts
@@ -1,7 +1,7 @@
 import path from 'path';
 import ini from 'ini';
 import { EOL } from 'os';
-import { sortBy, words, pad, padEnd, repeat, orderBy } from 'lodash';
+import { sortBy, words, pad, padEnd, repeat, orderBy, cloneDeep } from 'lodash';
 import { titleCase } from 'title-case';
 import {
   statSync,
@@ -25,7 +25,8 @@ import {
   ValueSet,
   CodeSystem,
   InstanceDefinition,
-  ImplementationGuideDefinitionPage
+  ImplementationGuideDefinitionPage,
+  ImplementationGuideDependsOn
 } from '../fhirtypes';
 import { ConfigurationMenuItem } from '../fshtypes';
 import { logger, Type } from '../utils';
@@ -158,29 +159,9 @@ export class IGExporter {
     if (this.config.dependencies?.length) {
       const igs = this.fhirDefs.allImplementationGuides();
       for (const dependency of this.config.dependencies) {
-        const dependencyIG = igs.find(
-          ig =>
-            ig.packageId === dependency.packageId &&
-            (ig.version === dependency.version ||
-              'current' === dependency.version ||
-              'dev' === dependency.version)
-        );
-        if (dependencyIG?.url) {
-          this.ig.dependsOn.push({
-            uri: dependencyIG.url,
-            packageId: dependency.packageId,
-            // packageId should be "a..z, A..Z, 0..9, and _ and it must start with a..z | A..Z" per
-            // https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/SUSHI.200.2E12.2E7/near/199193333
-            // depId should be [A-Za-z0-9\-\.]{1,64}, so we replace . and - with _ and prepend "id_" if it does not start w/ a-z|A-Z
-            id: /[A-Za-z]/.test(dependency.packageId[0])
-              ? dependency.packageId.replace(/\.|-/g, '_')
-              : 'id_' + dependency.packageId.replace(/\.|-/g, '_'),
-            version: dependency.version
-          });
-        } else {
-          logger.error(
-            `Failed to add ${dependency.packageId}:${dependency.version} to ImplementationGuide instance.  Could not determine its canonical URL from the FHIR cache.`
-          );
+        const dependsEntry = this.fixDependsOn(dependency, igs);
+        if (dependsEntry) {
+          this.ig.dependsOn.push(dependsEntry);
         }
       }
     } else {
@@ -200,6 +181,65 @@ export class IGExporter {
     }
   }
 
+  /**
+   * Fixes a dependsOn entry by specifying its uri (if not yet specified) and generating an id (if
+   * not yet specified). Will also ensure that required properties (uri/version) are available.
+   * If it cannot ensure a valid dependsOn entry, it will return undefined.
+   * @param dependency - the dependency to fix
+   * @param igs - the IGs to search when finding the dependency URI
+   * @returns the fixed dependency or null if it can't be fixed
+   */
+  private fixDependsOn(dependency: ImplementationGuideDependsOn, igs: any[]) {
+    // Clone it so we don't mutate the original
+    const dependsOn = cloneDeep(dependency);
+
+    if (dependsOn.version == null) {
+      // No need for the detailed log message since we already logged one in the package loader.
+      logger.error(
+        `Failed to add ${dependency.packageId} to ImplementationGuide instance because no ` +
+          'version was specified in your config.yaml.'
+      );
+      return;
+    }
+
+    if (dependsOn.uri == null) {
+      // Need to find the URI from the IG in the FHIR cache
+      const dependencyIG = igs.find(
+        ig =>
+          ig.packageId === dependsOn.packageId &&
+          (ig.version === dependsOn.version ||
+            'current' === dependsOn.version ||
+            'dev' === dependsOn.version)
+      );
+      dependsOn.uri = dependencyIG?.url;
+
+      if (dependsOn.uri == null) {
+        logger.error(
+          `Failed to add ${dependsOn.packageId}:${dependsOn.version} to ` +
+            'ImplementationGuide instance because SUSHI could not find the IG URL in the ' +
+            'dependency IG. To specify the IG URL in your config.yaml, use the dependency ' +
+            'details format:\n\n' +
+            'dependencies:\n' +
+            `  ${dependsOn.packageId}:\n` +
+            '    uri: http://my-fhir-ig.org/ImplementationGuide/123\n' +
+            `    version: ${dependsOn.version}`
+        );
+        return;
+      }
+    }
+
+    if (dependsOn.id == null) {
+      // packageId should be "a..z, A..Z, 0..9, and _ and it must start with a..z | A..Z" per
+      // https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/SUSHI.200.2E12.2E7/near/199193333
+      // depId should be [A-Za-z0-9\-\.]{1,64}, so we replace . and - with _ and prepend "id_" if it does not start w/ a-z|A-Z
+      dependsOn.id = /[A-Za-z]/.test(dependsOn.packageId[0])
+        ? dependsOn.packageId.replace(/\.|-/g, '_')
+        : 'id_' + dependsOn.packageId.replace(/\.|-/g, '_');
+    }
+
+    return dependsOn;
+  }
+
   /**
    * Add the static files that (currently) do not change from IG to IG.
    *
diff --git a/src/import/YAMLConfiguration.ts b/src/import/YAMLConfiguration.ts
index 604384c..2a6b216 100644
--- a/src/import/YAMLConfiguration.ts
+++ b/src/import/YAMLConfiguration.ts
@@ -297,6 +297,15 @@ export type YAMLConfigurationJurisdiction =
 
 export type YAMLConfigurationDependencyMap = {
   [key: string]:
+    | ImplementationGuideDependsOn['version'] // string
+    | number // YAML will parse some versions as numbers (e.g., 1.2)
+    | YAMLConfigurationDependencyDetails;
+};
+
+export type YAMLConfigurationDependencyDetails = {
+  id: ImplementationGuideDependsOn['id']; // string
+  uri: ImplementationGuideDependsOn['uri']; // string
+  version:
     | ImplementationGuideDependsOn['version'] // string
     | number; // YAML will parse some versions as numbers (e.g., 1.2)
 };
diff --git a/src/import/importConfiguration.ts b/src/import/importConfiguration.ts
index d40abc7..f146317 100644
--- a/src/import/importConfiguration.ts
+++ b/src/import/importConfiguration.ts
@@ -536,8 +536,24 @@ function parseDependencies(
   if (yamlDependencies == null) {
     return;
   }
-  return Object.entries(yamlDependencies).map(([packageId, version]) => {
-    return { packageId, version: `${version}` };
+  return Object.entries(yamlDependencies).map(([packageId, versionOrDetails]) => {
+    if (typeof versionOrDetails === 'string' || typeof versionOrDetails === 'number') {
+      return { packageId, version: `${versionOrDetails}` };
+    } else if (versionOrDetails == null) {
+      // this is an error condition, but we'll just make it an incomplete dependency and
+      // handle the error later
+      return { packageId };
+    }
+    // else it's the complex object
+    return removeUndefinedValues({
+      id: versionOrDetails.id,
+      packageId,
+      uri: versionOrDetails.uri,
+      version:
+        typeof versionOrDetails.version === 'string' || typeof versionOrDetails.version === 'number'
+          ? `${versionOrDetails.version}`
+          : undefined
+    });
   });
 }
 
diff --git a/src/utils/Processing.ts b/src/utils/Processing.ts
index f0adbb3..7fafbea 100644
--- a/src/utils/Processing.ts
+++ b/src/utils/Processing.ts
@@ -83,6 +83,20 @@ export function loadExternalDependencies(
   // Load dependencies
   const dependencyDefs: Promise<FHIRDefinitions | void>[] = [];
   for (const dep of dependencies) {
+    if (dep.version == null) {
+      logger.error(
+        `Failed to load ${dep.packageId}: No version specified. To specify the version in your ` +
+          'config.yaml, either use the simple dependency format:\n\n' +
+          'dependencies:\n' +
+          `  ${dep.packageId}: current\n\n` +
+          'or use the detailed dependency format to specify other properties as well:\n\n' +
+          'dependencies:\n' +
+          `  ${dep.packageId}:\n` +
+          `    uri: ${dep.uri ?? 'http://my-fhir-ig.org/ImplementationGuide/123'}\n` +
+          '    version: current'
+      );
+      continue;
+    }
     dependencyDefs.push(
       loadDependency(dep.packageId, dep.version, defs)
         .then(def => {


Test patch:
diff --git a/test/ig/IGExporter.IG.test.ts b/test/ig/IGExporter.IG.test.ts
index 22220c4..a288ae8 100644
--- a/test/ig/IGExporter.IG.test.ts
+++ b/test/ig/IGExporter.IG.test.ts
@@ -7,7 +7,8 @@ import {
   InstanceDefinition,
   CodeSystem,
   ImplementationGuideDefinitionResource,
-  ImplementationGuide
+  ImplementationGuide,
+  ImplementationGuideDependsOn
 } from '../../src/fhirtypes';
 import { Package } from '../../src/export';
 import { Configuration } from '../../src/fshtypes';
@@ -99,7 +100,13 @@ describe('IGExporter', () => {
         description: 'Provides a simple example of how FSH can be used to create an IG',
         dependencies: [
           { packageId: 'hl7.fhir.us.core', version: '3.1.0' },
-          { packageId: 'hl7.fhir.uv.vhdir', version: 'current' }
+          { packageId: 'hl7.fhir.uv.vhdir', version: 'current' },
+          {
+            packageId: 'hl7.fhir.us.mcode',
+            uri: 'http://hl7.org/fhir/us/mcode/ImplementationGuide/hl7.fhir.us.mcode',
+            id: 'mcode',
+            version: '1.0.0'
+          }
         ],
         status: 'active',
         template: 'fhir.base.template',
@@ -211,6 +218,13 @@ describe('IGExporter', () => {
             uri: 'http://hl7.org/fhir/uv/vhdir/ImplementationGuide/hl7.core.uv.vhdir',
             packageId: 'hl7.fhir.uv.vhdir',
             version: 'current'
+          },
+          // mCODE tests that it works with the url and id explicitly provided in the config
+          {
+            id: 'mcode',
+            uri: 'http://hl7.org/fhir/us/mcode/ImplementationGuide/hl7.fhir.us.mcode',
+            packageId: 'hl7.fhir.us.mcode',
+            version: '1.0.0'
           }
         ],
         definition: {
@@ -306,6 +320,59 @@ describe('IGExporter', () => {
       });
     });
 
+    it('should issue an error when a dependency url cannot be inferred', () => {
+      config.dependencies = [
+        // NOTE: Will not find mCODE IG URL because we didn't load the mcode IG
+        { packageId: 'hl7.fhir.us.mcode', version: '1.0.0' },
+        { packageId: 'hl7.fhir.us.core', version: '3.1.0' }
+      ];
+      exporter.export(tempOut);
+      const igPath = path.join(tempOut, 'input', 'ImplementationGuide-sushi-test.json');
+      expect(fs.existsSync(igPath)).toBeTruthy();
+      const content = fs.readJSONSync(igPath);
+      const dependencies: ImplementationGuideDependsOn[] = content.dependsOn;
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Failed to add hl7\.fhir\.us\.mcode:1\.0\.0 to ImplementationGuide instance .* IG URL/
+      );
+      // Ensure US Core is exported but mCODE is not
+      expect(dependencies).toEqual([
+        {
+          id: 'hl7_fhir_us_core',
+          uri: 'http://hl7.org/fhir/us/core/ImplementationGuide/hl7.fhir.us.core',
+          packageId: 'hl7.fhir.us.core',
+          version: '3.1.0'
+        }
+      ]);
+    });
+
+    it('should issue an error when a dependency version is not provided', () => {
+      config.dependencies = [
+        // NOTE: version is intentionally missing
+        {
+          packageId: 'hl7.fhir.us.mcode',
+          uri: 'http://hl7.org/fhir/us/core/ImplementationGuide/hl7.fhir.us.core'
+        },
+        { packageId: 'hl7.fhir.us.core', version: '3.1.0' }
+      ];
+      exporter.export(tempOut);
+      const igPath = path.join(tempOut, 'input', 'ImplementationGuide-sushi-test.json');
+      expect(fs.existsSync(igPath)).toBeTruthy();
+      const content = fs.readJSONSync(igPath);
+      const dependencies: ImplementationGuideDependsOn[] = content.dependsOn;
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /Failed to add hl7\.fhir\.us\.mcode to ImplementationGuide instance .* no version/
+      );
+      // Ensure US Core is exported but mCODE is not
+      expect(dependencies).toEqual([
+        {
+          id: 'hl7_fhir_us_core',
+          uri: 'http://hl7.org/fhir/us/core/ImplementationGuide/hl7.fhir.us.core',
+          packageId: 'hl7.fhir.us.core',
+          version: '3.1.0'
+        }
+      ]);
+    });
+
     it('should override generated resource attributes with configured attributes', () => {
       config.resources = [
         {
diff --git a/test/import/YAMLConfiguration.test.ts b/test/import/YAMLConfiguration.test.ts
index 7b05a2f..435cc1a 100644
--- a/test/import/YAMLConfiguration.test.ts
+++ b/test/import/YAMLConfiguration.test.ts
@@ -49,7 +49,12 @@ describe('YAMLConfiguration', () => {
       ]);
       expect(config.jurisdiction).toBe('urn:iso:std:iso:3166#US "United States of America"');
       expect(config.dependencies).toEqual({
-        'hl7.fhir.us.core': '3.1.0'
+        'hl7.fhir.us.core': '3.1.0',
+        'hl7.fhir.us.mcode': {
+          id: 'mcode',
+          uri: 'http://hl7.org/fhir/us/mcode/ImplementationGuide/hl7.fhir.us.mcode',
+          version: '1.0.0'
+        }
       });
       expect(config.global).toEqual({
         Patient: 'http://example.org/fhir/StructureDefinition/my-patient-profile',
diff --git a/test/import/fixtures/example-config.yaml b/test/import/fixtures/example-config.yaml
index 8e80a28..6223f5b 100644
--- a/test/import/fixtures/example-config.yaml
+++ b/test/import/fixtures/example-config.yaml
@@ -59,9 +59,15 @@ contact:
 jurisdiction: urn:iso:std:iso:3166#US "United States of America"
 
 # The dependencies property corresponds to IG.dependsOn. The key is the
-# package id and the value is the version (or dev/current).
+# package id and the value is the version (or dev/current). For advanced
+# use cases a more complex value can be used with id, uri, and/or version
+# specified.
 dependencies:
   hl7.fhir.us.core: 3.1.0
+  hl7.fhir.us.mcode:
+    id: mcode
+    uri: http://hl7.org/fhir/us/mcode/ImplementationGuide/hl7.fhir.us.mcode
+    version: 1.0.0
 
 # The global property corresponds to the IG.global property, but it
 # uses the type as the YAML key and the profile as its value. Since
diff --git a/test/import/importConfiguration.test.ts b/test/import/importConfiguration.test.ts
index 617a284..fd2f4a5 100644
--- a/test/import/importConfiguration.test.ts
+++ b/test/import/importConfiguration.test.ts
@@ -86,7 +86,15 @@ describe('importConfiguration', () => {
           ]
         }
       ],
-      dependencies: [{ packageId: 'hl7.fhir.us.core', version: '3.1.0' }],
+      dependencies: [
+        { packageId: 'hl7.fhir.us.core', version: '3.1.0' },
+        {
+          id: 'mcode',
+          packageId: 'hl7.fhir.us.mcode',
+          uri: 'http://hl7.org/fhir/us/mcode/ImplementationGuide/hl7.fhir.us.mcode',
+          version: '1.0.0'
+        }
+      ],
       global: [
         {
           type: 'Patient',
diff --git a/test/utils/Processing.test.ts b/test/utils/Processing.test.ts
index 9ae7c25..702b239 100644
--- a/test/utils/Processing.test.ts
+++ b/test/utils/Processing.test.ts
@@ -197,6 +197,20 @@ describe('Processing', () => {
         );
       });
     });
+
+    it('should log an error when a dependency has no specified version', () => {
+      const badDependencyConfig = cloneDeep(minimalConfig);
+      badDependencyConfig.dependencies = [{ packageId: 'hl7.fhir.r4.core' }];
+      const defs = new FHIRDefinitions();
+      const dependencyDefs = loadExternalDependencies(defs, badDependencyConfig);
+      return Promise.all(dependencyDefs).then(() => {
+        expect(defs.packages.length).toBe(1);
+        expect(defs.packages).toContain('hl7.fhir.r4.core#4.0.1');
+        expect(loggerSpy.getLastMessage('error')).toMatch(
+          /Failed to load hl7\.fhir\.r4\.core: No version specified\./s
+        );
+      });
+    });
   });
 
   describe('#getRawFSHes()', () => {