Repository: FHIR/sushi
Pull request #1091 (2022-05-18 20:46:22)

Friendlier Configuration
This is a TWOFER:

First, it adds support for referring to resources by their name or id in sushi-config.yaml.  This affects the following:
- global profile values (previous required canonical)
- group resources (previously required relative URL)
- resources (previously required relative URL)
- resources exampleCanonicals (previously required canonical)

Fixes: #1018

Second, it fixes the error that occurred if you defined a group without any resources.

Fixes: #1019

Unit tests demonstrate the fixes, but you can also manually try it out via this SUSHI project: [FriendlierConfig.zip](https://github.com/FHIR/sushi/files/8720814/FriendlierConfig.zip)


Patch:
diff --git a/src/fshtypes/Configuration.ts b/src/fshtypes/Configuration.ts
index bc112e3..debbef2 100644
--- a/src/fshtypes/Configuration.ts
+++ b/src/fshtypes/Configuration.ts
@@ -72,7 +72,8 @@ export type Configuration = {
   // authors use the default groups that are provided by the templating framework, but if authors
   // want to use their own instead, they can use the mechanism below.  This will create
   // IG.definition.grouping entries and associate the individual resource entries with the
-  // corresponding groupIds.
+  // corresponding groupIds. If a resource is specified by id or name, SUSHI will replace it with
+  // the correct URL when generating the IG JSON.
   groups?: ConfigurationGroup[];
 
   // The resources property corresponds to IG.definition.resource. SUSHI can auto-generate all of
@@ -81,6 +82,10 @@ export type Configuration = {
   // author can add entries here. If the reference matches a generated entry, it will replace the
   // generated entry. If it doesn't match any generated entries, it will be added to the generated
   // entries. The format follows IG.definition.resource with the following differences:
+  // * if the key is an id or name, SUSHI will replace it with the correct URL when generating the
+  //   IG JSON.
+  // * if the exampleCanonical is an id or name, SUSHI will replace it with the correct canonical
+  //   when generating the IG JSON.
   // * additional "omit" property to omit a FSH-generated resource from the resource list.
   // * groupingId can be used, but top-level groups syntax may be a better option (see below).
   resources?: ConfigurationResource[];
@@ -140,7 +145,7 @@ export type ConfigurationGroup = {
   id: string;
   name: string;
   description?: string;
-  resources: string[];
+  resources?: string[];
 };
 
 export type ConfigurationResource = ImplementationGuideDefinitionResource & { omit?: boolean };
diff --git a/src/ig/IGExporter.ts b/src/ig/IGExporter.ts
index c43a665..2098fa4 100644
--- a/src/ig/IGExporter.ts
+++ b/src/ig/IGExporter.ts
@@ -31,8 +31,10 @@ import { FHIRDefinitions } from '../fhirdefs';
 import { Configuration } from '../fshtypes';
 
 // List of Conformance and Terminology resources from http://hl7.org/fhir/R4/resourcelist.html
+// and http://hl7.org/fhir/5.0.0-snapshot1/resourcelist.html
 const CONFORMANCE_AND_TERMINOLOGY_RESOURCES = new Set([
   'CapabilityStatement',
+  'CapabilityStatement2', // R5
   'StructureDefinition',
   'ImplementationGuide',
   'SearchParameter',
@@ -45,6 +47,7 @@ const CONFORMANCE_AND_TERMINOLOGY_RESOURCES = new Set([
   'CodeSystem',
   'ValueSet',
   'ConceptMap',
+  'ConceptMap2', // R5
   'NamingSystem',
   'TerminologyCapabilities'
 ]);
@@ -79,6 +82,7 @@ export class IGExporter {
    */
   export(outPath: string) {
     ensureDirSync(outPath);
+    this.normalizeResourceReferences();
     this.initIG();
     this.addResources();
     this.addPredefinedResources();
@@ -96,6 +100,30 @@ export class IGExporter {
     this.addImplementationGuide(outPath);
   }
 
+  /**
+   * Normalizes FSHy ids and names to the required relative URLs or canonicals
+   */
+  normalizeResourceReferences() {
+    this.config.global?.forEach(g => {
+      if (g.profile) {
+        g.profile = this.normalizeResourceReference(g.profile, false);
+      }
+    });
+    this.config.groups?.forEach(g => {
+      if (g.resources) {
+        g.resources = g.resources.map(r => this.normalizeResourceReference(r, true));
+      }
+    });
+    this.config.resources?.forEach(r => {
+      if (r.reference?.reference) {
+        r.reference.reference = this.normalizeResourceReference(r.reference.reference, true);
+      }
+      if (r.exampleCanonical) {
+        r.exampleCanonical = this.normalizeResourceReference(r.exampleCanonical, false);
+      }
+    });
+  }
+
   /**
    * Initializes the ImplementationGuide JSON w/ data from the configuration YAML
    *
@@ -1041,25 +1069,27 @@ export class IGExporter {
   private addConfiguredGroups(): void {
     for (const group of this.config.groups ?? []) {
       this.addGroup(group.id, group.name, group.description);
-      for (const resourceKey of group.resources) {
-        const existingResource = this.ig.definition.resource.find(
-          resource => resource.reference?.reference === resourceKey
-        );
-        if (!existingResource) {
-          logger.error(`Group ${group.id} configured with nonexistent resource ${resourceKey}`);
-        } else {
-          if (existingResource.groupingId) {
-            if (existingResource.groupingId === group.id) {
-              logger.warn(
-                `Resource ${resourceKey} is listed as a member of group ${group.id}, and does not need a groupingId.`
-              );
-            } else {
-              logger.error(
-                `Resource ${resourceKey} configured with groupingId ${existingResource.groupingId}, but listed as member of group ${group.id}.`
-              );
+      if (group.resources) {
+        for (const resourceKey of group.resources) {
+          const existingResource = this.ig.definition.resource.find(
+            resource => resource.reference?.reference === resourceKey
+          );
+          if (!existingResource) {
+            logger.error(`Group ${group.id} configured with nonexistent resource ${resourceKey}`);
+          } else {
+            if (existingResource.groupingId) {
+              if (existingResource.groupingId === group.id) {
+                logger.warn(
+                  `Resource ${resourceKey} is listed as a member of group ${group.id}, and does not need a groupingId.`
+                );
+              } else {
+                logger.error(
+                  `Resource ${resourceKey} configured with groupingId ${existingResource.groupingId}, but listed as member of group ${group.id}.`
+                );
+              }
             }
+            existingResource.groupingId = group.id;
           }
-          existingResource.groupingId = group.id;
         }
       }
     }
@@ -1083,6 +1113,31 @@ export class IGExporter {
     }
   }
 
+  private normalizeResourceReference(resource: string, useRelative: boolean): string {
+    let ref;
+    // If it doesn't contain / or :, then it's not a relative URL or canonical yet
+    if (!/[/:]/.test(resource)) {
+      let def = this.pkg.fishForFHIR(resource);
+      if (def == null) {
+        def = this.fhirDefs.fishForFHIR(resource);
+      }
+      if (useRelative && def?.resourceType && def?.id) {
+        ref = `${def.resourceType}/${def.id}`;
+      } else if (def?.url) {
+        ref = def.url;
+      }
+      if (ref == null) {
+        logger.warn(
+          `Cannot determine ${
+            useRelative ? 'relative URL' : 'canonical'
+          } for "${resource}" referenced in sushi-config.yaml.`
+        );
+      }
+    }
+    // Fallback to resource if we didn't assign a fullRef
+    return ref ?? resource;
+  }
+
   /**
    * Writes the in-memory ImplementationGuide JSON to the IG output folder.
    *
diff --git a/src/import/YAMLConfiguration.ts b/src/import/YAMLConfiguration.ts
index a49569f..853b718 100644
--- a/src/import/YAMLConfiguration.ts
+++ b/src/import/YAMLConfiguration.ts
@@ -90,14 +90,16 @@ export type YAMLConfiguration = {
   // 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 FHIR does not explicitly disallow more than one
   // profile per type, neither do we; the value can be a single profile URL or an array of profile
-  // URLs.
+  // URLs. If a value is an id or name, SUSHI will replace it with the correct canonical when
+  // generating the IG JSON.
   global?: YAMLConfigurationGlobalMap;
 
   // Groups can control certain aspects of the IG generation.  The IG documentation recommends that
   // authors use the default groups that are provided by the templating framework, but if authors
   // want to use their own instead, they can use the mechanism below.  This will create
   // IG.definition.grouping entries and associate the individual resource entries with the
-  // corresponding groupIds.
+  // corresponding groupIds. If a resource is specified by id or name, SUSHI will replace it with
+  // the correct URL when generating the IG JSON.
   groups?: YAMLConfigurationGroupMap;
 
   // The resources property corresponds to IG.definition.resource. SUSHI can auto-generate all of
@@ -106,8 +108,12 @@ export type YAMLConfiguration = {
   // author can add entries here. If the reference matches a generated entry, it will replace the
   // generated entry. If it doesn't match any generated entries, it will be added to the generated
   // entries. The format follows IG.definition.resource with the following differences:
-  // * use IG.definition.resource.reference.reference as the YAML key (so reference is optional)
+  // * use IG.definition.resource.reference.reference as the YAML key (so reference is optional).
+  // * if the key is an id or name, SUSHI will replace it with the correct URL when generating the
+  //   IG JSON.
   // * specify "omit" to omit a FSH-generated resource from the resource list.
+  // * if the exampleCanonical is an id or name, SUSHI will replace it with the correct canonical
+  //   when generating the IG JSON.
   // * groupingId can be used, but top-level groups syntax may be a better option (see below).
   resources?: YAMLConfigurationResourceMap;
 
diff --git a/src/utils/init-project/sushi-config.yaml b/src/utils/init-project/sushi-config.yaml
index fa13c43..77c8a46 100644
--- a/src/utils/init-project/sushi-config.yaml
+++ b/src/utils/init-project/sushi-config.yaml
@@ -97,7 +97,8 @@ menu:
 # uses the type as the YAML key and the profile as its value. Since
 # FHIR does not explicitly disallow more than one profile per type,
 # neither do we; the value can be a single profile URL or an array
-# of profile URLs.
+# of profile URLs. If a value is an id or name, SUSHI will replace
+# it with the correct canonical when generating the IG JSON.
 #
 # global:
 #   Patient: http://example.org/fhir/StructureDefinition/my-patient-profile
@@ -113,9 +114,13 @@ menu:
 # replace the generated entry. If it doesn't match any generated
 # entries, it will be added to the generated entries. The format
 # follows IG.definition.resource with the following differences:
-#   * use IG.definition.resource.reference.reference as the YAML key
+#   * use IG.definition.resource.reference.reference as the YAML key.
+#   * if the key is an id or name, SUSHI will replace it with the
+#     correct URL when generating the IG JSON.
 #   * specify "omit" to omit a FSH-generated resource from the
 #     resource list.
+#   * if the exampleCanonical is an id or name, SUSHI will replace
+#     it with the correct canonical when generating the IG JSON.
 #   * groupingId can be used, but top-level groups syntax may be a
 #     better option (see below).
 # The following are simple examples to demonstrate what this might
@@ -134,7 +139,9 @@ menu:
 # are provided by the templating framework, but if authors want to
 # use their own instead, they can use the mechanism below.  This will
 # create IG.definition.grouping entries and associate the individual
-# resource entries with the corresponding groupIds.
+# resource entries with the corresponding groupIds. If a resource
+# is specified by id or name, SUSHI will replace it with the correct
+# URL when generating the IG JSON.
 #
 # groups:
 #   GroupA:


Test patch:
diff --git a/test/ig/IGExporter.IG.test.ts b/test/ig/IGExporter.IG.test.ts
index 337c500..4b0240c 100644
--- a/test/ig/IGExporter.IG.test.ts
+++ b/test/ig/IGExporter.IG.test.ts
@@ -600,6 +600,11 @@ describe('IGExporter', () => {
           name: 'My Observation Group',
           description: 'Group for some observation-related things.',
           resources: ['StructureDefinition/sample-observation']
+        },
+        {
+          id: 'MyEmptyGroup',
+          name: 'My Empty Group',
+          description: 'A group for when nothing is better than something.'
         }
       ];
       exporter.export(tempOut);
@@ -621,6 +626,11 @@ describe('IGExporter', () => {
         name: 'My Observation Group',
         description: 'Group for some observation-related things.'
       });
+      expect(content.definition.grouping).toContainEqual({
+        id: 'MyEmptyGroup',
+        name: 'My Empty Group',
+        description: 'A group for when nothing is better than something.'
+      });
       const samplePatient: ImplementationGuideDefinitionResource = content.definition.resource.find(
         (r: ImplementationGuideDefinitionResource) =>
           r?.reference?.reference === 'StructureDefinition/sample-patient'
@@ -1792,6 +1802,494 @@ describe('IGExporter', () => {
     });
   });
 
+  describe('#ref-by-id-or-name', () => {
+    let pkg: Package;
+    let tempOut: string;
+    let defs: FHIRDefinitions;
+
+    beforeAll(() => {
+      defs = new FHIRDefinitions();
+      loadFromPath(path.join(__dirname, '..', 'testhelpers', 'testdefs'), 'r4-definitions', defs);
+    });
+
+    beforeEach(() => {
+      loggerSpy.reset();
+      const fixtures = path.join(__dirname, 'fixtures', 'simple-ig');
+      tempOut = temp.mkdirSync('sushi-test');
+      const config: Configuration = {
+        filePath: path.join(fixtures, 'sushi-config.yml'),
+        id: 'sushi-test',
+        canonical: 'http://hl7.org/fhir/sushi-test',
+        name: 'FSHTestIG',
+        status: 'active',
+        fhirVersion: ['4.0.1'],
+        parameters: [
+          {
+            code: 'copyrightyear',
+            value: '2020+'
+          },
+          {
+            code: 'releaselabel',
+            value: 'CI Build'
+          }
+        ]
+      };
+      pkg = new Package(config);
+
+      // Profile: StructureDefinition/sample-patient
+      pkg.profiles.push(
+        StructureDefinition.fromJSON(
+          fs.readJSONSync(
+            path.join(fixtures, 'profiles', 'StructureDefinition-sample-patient.json')
+          )
+        )
+      );
+
+      // Extension: StructureDefinition/sample-value-extension
+      pkg.extensions.push(
+        StructureDefinition.fromJSON(
+          fs.readJSONSync(
+            path.join(fixtures, 'extensions', 'StructureDefinition-sample-value-extension.json')
+          )
+        )
+      );
+
+      // CodeSystem: StructureDefinition/sample-code-system
+      const codeSystemDef = new CodeSystem();
+      codeSystemDef.id = 'sample-code-system';
+      codeSystemDef.name = 'SampleCodeSystem';
+      codeSystemDef.url = 'http://hl7.org/fhir/sushi-test/StructureDefinition/sample-code-system';
+      codeSystemDef.description = 'A code system description';
+      pkg.codeSystems.push(codeSystemDef);
+
+      // Example: Patient/patient-example
+      const instanceDef = InstanceDefinition.fromJSON(
+        fs.readJSONSync(path.join(fixtures, 'examples', 'Patient-example.json'))
+      );
+      instanceDef._instanceMeta.usage = 'Example';
+      pkg.instances.push(instanceDef);
+    });
+
+    afterAll(() => {
+      temp.cleanupSync();
+    });
+
+    const doResourceTest = (
+      profile: string,
+      extension: string,
+      codeSystem: string,
+      example: string,
+      skipExpectations = false
+    ) => {
+      // Setup resources
+      pkg.config.resources = [
+        // Profile: StructureDefinition/sample-patient
+        {
+          reference: { reference: profile },
+          name: 'A Very Special Profile' // override name to ensure correct match in expectations
+        },
+        // Extension: StructureDefinition/sample-value-extension
+        {
+          reference: { reference: extension },
+          name: 'A Very Special Extension' // override name to ensure correct match in expectations
+        },
+        // CodeSystem: StructureDefinition/sample-code-system
+        {
+          reference: { reference: codeSystem },
+          name: 'A Very Special CodeSystem' // override name to ensure correct match in expectations
+        },
+        // Example: Patient/patient-example
+        {
+          reference: { reference: example },
+          name: 'A Very Special Example' // override name to ensure correct match in expectations
+        }
+      ];
+      // Export and check
+      const fixtures = path.join(__dirname, 'fixtures', 'simple-ig');
+      const exporter = new IGExporter(pkg, defs, fixtures);
+      exporter.export(tempOut);
+      const igPath = path.join(
+        tempOut,
+        'fsh-generated',
+        'resources',
+        'ImplementationGuide-sushi-test.json'
+      );
+      expect(fs.existsSync(igPath)).toBeTruthy();
+      const igJSON = fs.readJSONSync(igPath);
+      if (!skipExpectations) {
+        expect(loggerSpy.getAllMessages('warn')).toHaveLength(0);
+        expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+        expect(igJSON.definition.resource).toEqual([
+          {
+            reference: {
+              reference: 'StructureDefinition/sample-patient'
+            },
+            name: 'A Very Special Profile',
+            description:
+              'Demographics and other administrative information about an individual or animal receiving care or other health-related services.',
+            exampleBoolean: false
+          },
+          {
+            reference: {
+              reference: 'StructureDefinition/sample-value-extension'
+            },
+            name: 'A Very Special Extension',
+            description:
+              'Base StructureDefinition for Extension Type: Optional Extension Element - found in all resources.',
+            exampleBoolean: false
+          },
+          {
+            reference: {
+              reference: 'CodeSystem/sample-code-system'
+            },
+            name: 'A Very Special CodeSystem',
+            description: 'A code system description',
+            exampleBoolean: false
+          },
+          {
+            reference: {
+              reference: 'Patient/patient-example'
+            },
+            name: 'A Very Special Example',
+            exampleBoolean: true
+          }
+        ]);
+      }
+      return igJSON;
+    };
+
+    it('should properly convert resource ids to relative URLs', () => {
+      doResourceTest(
+        'sample-patient',
+        'sample-value-extension',
+        'sample-code-system',
+        'patient-example'
+      );
+    });
+
+    it('should properly convert resource names to relative URLs', () => {
+      doResourceTest(
+        'SamplePatient',
+        'SampleValueExtension',
+        'SampleCodeSystem',
+        'Patient/patient-example' // patients don't have a formal name
+      );
+    });
+
+    it('should properly retain relative resource URLs', () => {
+      doResourceTest(
+        'StructureDefinition/sample-patient',
+        'StructureDefinition/sample-value-extension',
+        'CodeSystem/sample-code-system',
+        'Patient/patient-example'
+      );
+    });
+
+    it('should log a warning and use reference as-is when a name/id cannot be resolved', () => {
+      const igJSON = doResourceTest(
+        'invalid-id',
+        'sample-value-extension',
+        'sample-code-system',
+        'patient-example',
+        true
+      );
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(1);
+      expect(loggerSpy.getLastMessage('warn')).toMatch(
+        'Cannot determine relative URL for "invalid-id" referenced in sushi-config.yaml.'
+      );
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+      expect(igJSON.definition.resource).toHaveLength(5);
+      expect(igJSON.definition.resource).toContainEqual({
+        reference: { reference: 'invalid-id' },
+        name: 'A Very Special Profile'
+      });
+    });
+
+    const doExampleCanonicalTest = (exampleCanonical: string, skipExpectations = false) => {
+      // Remove extension and code system since we don't need them
+      pkg.extensions.length = 0;
+      pkg.codeSystems.length = 0;
+      // Setup resources
+      pkg.config.resources = [
+        // Profile: StructureDefinition/sample-patient
+        {
+          reference: { reference: 'StructureDefinition/sample-patient' }
+        },
+        // Example: Patient/patient-example
+        {
+          reference: { reference: 'Patient/patient-example' },
+          exampleCanonical
+        }
+      ];
+      // Export and check
+      const fixtures = path.join(__dirname, 'fixtures', 'simple-ig');
+      const exporter = new IGExporter(pkg, defs, fixtures);
+      exporter.export(tempOut);
+      const igPath = path.join(
+        tempOut,
+        'fsh-generated',
+        'resources',
+        'ImplementationGuide-sushi-test.json'
+      );
+      expect(fs.existsSync(igPath)).toBeTruthy();
+      const igJSON = fs.readJSONSync(igPath);
+      if (!skipExpectations) {
+        expect(loggerSpy.getAllMessages('warn')).toHaveLength(0);
+        expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+        expect(igJSON.definition.resource).toEqual([
+          {
+            reference: {
+              reference: 'StructureDefinition/sample-patient'
+            },
+            name: 'SamplePatient',
+            description:
+              'Demographics and other administrative information about an individual or animal receiving care or other health-related services.',
+            exampleBoolean: false
+          },
+          {
+            reference: {
+              reference: 'Patient/patient-example'
+            },
+            name: 'patient-example',
+            exampleCanonical: 'http://hl7.org/fhir/sushi-test/StructureDefinition/sample-patient'
+          }
+        ]);
+      }
+      return igJSON;
+    };
+
+    it('should properly convert exampleCanonical id to canonical', () => {
+      doExampleCanonicalTest('sample-patient');
+    });
+
+    it('should properly convert exampleCanonical name to canonical', () => {
+      doExampleCanonicalTest('SamplePatient');
+    });
+
+    it('should properly retain canonical exampleCanonical', () => {
+      doExampleCanonicalTest('http://hl7.org/fhir/sushi-test/StructureDefinition/sample-patient');
+    });
+
+    it('should properly convert exampleCanonical for external profiles too', () => {
+      const igJSON = doExampleCanonicalTest('us-core-patient', true);
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(0);
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+      expect(igJSON.definition.resource).toContainEqual({
+        reference: {
+          reference: 'Patient/patient-example'
+        },
+        name: 'patient-example',
+        exampleCanonical: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'
+      });
+    });
+
+    it('should log a warning and use reference as-is when a name/id cannot be resolved', () => {
+      const igJSON = doExampleCanonicalTest('invalid-example-canonical-id', true);
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(1);
+      expect(loggerSpy.getLastMessage('warn')).toMatch(
+        'Cannot determine canonical for "invalid-example-canonical-id" referenced in sushi-config.yaml.'
+      );
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+      expect(igJSON.definition.resource).toHaveLength(2);
+      expect(igJSON.definition.resource).toContainEqual({
+        reference: {
+          reference: 'Patient/patient-example'
+        },
+        name: 'patient-example',
+        exampleCanonical: 'invalid-example-canonical-id'
+      });
+    });
+
+    const doGroupTest = (
+      profile: string,
+      extension: string,
+      codeSystem: string,
+      example: string,
+      skipExpectations = false
+    ) => {
+      pkg.config.groups = [
+        {
+          id: 'MyPatientGroup',
+          name: 'My Patient Group',
+          description: 'Group of patient things.',
+          resources: [profile, example]
+        },
+        {
+          id: 'MyOtherGroup',
+          name: 'My Other Group',
+          description: 'Group of other things.',
+          resources: [extension, codeSystem]
+        }
+      ];
+      const fixtures = path.join(__dirname, 'fixtures', 'simple-ig');
+      const exporter = new IGExporter(pkg, defs, fixtures);
+      exporter.export(tempOut);
+      const igPath = path.join(
+        tempOut,
+        'fsh-generated',
+        'resources',
+        'ImplementationGuide-sushi-test.json'
+      );
+      expect(fs.existsSync(igPath)).toBeTruthy();
+      const igJSON = fs.readJSONSync(igPath);
+      if (!skipExpectations) {
+        expect(igJSON.definition.grouping).toContainEqual({
+          id: 'MyPatientGroup',
+          name: 'My Patient Group',
+          description: 'Group of patient things.'
+        });
+        expect(igJSON.definition.grouping).toContainEqual({
+          id: 'MyOtherGroup',
+          name: 'My Other Group',
+          description: 'Group of other things.'
+        });
+        expect(
+          igJSON.definition.resource.find(
+            (r: ImplementationGuideDefinitionResource) =>
+              r?.reference?.reference === 'StructureDefinition/sample-patient'
+          )?.groupingId
+        ).toBe('MyPatientGroup');
+        expect(
+          igJSON.definition.resource.find(
+            (r: ImplementationGuideDefinitionResource) =>
+              r?.reference?.reference === 'Patient/patient-example'
+          )?.groupingId
+        ).toBe('MyPatientGroup');
+        expect(
+          igJSON.definition.resource.find(
+            (r: ImplementationGuideDefinitionResource) =>
+              r?.reference?.reference === 'StructureDefinition/sample-value-extension'
+          )?.groupingId
+        ).toBe('MyOtherGroup');
+        expect(
+          igJSON.definition.resource.find(
+            (r: ImplementationGuideDefinitionResource) =>
+              r?.reference?.reference === 'CodeSystem/sample-code-system'
+          )?.groupingId
+        ).toBe('MyOtherGroup');
+      }
+      return igJSON;
+    };
+
+    it('should properly convert resource ids to relative URLs', () => {
+      doGroupTest(
+        'sample-patient',
+        'sample-value-extension',
+        'sample-code-system',
+        'patient-example'
+      );
+    });
+
+    it('should properly convert resource names to relative URLs', () => {
+      doGroupTest(
+        'SamplePatient',
+        'SampleValueExtension',
+        'SampleCodeSystem',
+        'Patient/patient-example' // patients don't have a formal name
+      );
+    });
+
+    it('should properly retain relative resource URLs', () => {
+      doGroupTest(
+        'StructureDefinition/sample-patient',
+        'StructureDefinition/sample-value-extension',
+        'CodeSystem/sample-code-system',
+        'Patient/patient-example'
+      );
+    });
+
+    it('should log a warning and an error when a name/id cannot be resolved', () => {
+      const igJSON = doGroupTest(
+        'invalid-id',
+        'sample-value-extension',
+        'sample-code-system',
+        'patient-example',
+        true
+      );
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(1);
+      expect(loggerSpy.getLastMessage('warn')).toMatch(
+        'Cannot determine relative URL for "invalid-id" referenced in sushi-config.yaml.'
+      );
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(1);
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        'Group MyPatientGroup configured with nonexistent resource invalid-id'
+      );
+      expect(igJSON.definition.grouping).toHaveLength(2);
+      expect(igJSON.definition.resource).toHaveLength(4);
+    });
+
+    const doGlobalProfileTest = (profile: string, skipExpectations = false) => {
+      // Remove extension, code system, and example since we don't need them
+      pkg.extensions.length = 0;
+      pkg.codeSystems.length = 0;
+      pkg.instances.length = 0;
+      // Setup global profile
+      pkg.config.global = [{ type: 'Patient', profile }];
+      // Export and check
+      const fixtures = path.join(__dirname, 'fixtures', 'simple-ig');
+      const exporter = new IGExporter(pkg, defs, fixtures);
+      exporter.export(tempOut);
+      const igPath = path.join(
+        tempOut,
+        'fsh-generated',
+        'resources',
+        'ImplementationGuide-sushi-test.json'
+      );
+      expect(fs.existsSync(igPath)).toBeTruthy();
+      const igJSON = fs.readJSONSync(igPath);
+      if (!skipExpectations) {
+        expect(loggerSpy.getAllMessages('warn')).toHaveLength(0);
+        expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+        expect(igJSON.global).toEqual([
+          {
+            type: 'Patient',
+            profile: 'http://hl7.org/fhir/sushi-test/StructureDefinition/sample-patient'
+          }
+        ]);
+      }
+      return igJSON;
+    };
+
+    it('should properly convert global profile id to canonical', () => {
+      doGlobalProfileTest('sample-patient');
+    });
+
+    it('should properly convert global profile name to canonical', () => {
+      doGlobalProfileTest('SamplePatient');
+    });
+
+    it('should properly retain global profile canonical', () => {
+      doGlobalProfileTest('http://hl7.org/fhir/sushi-test/StructureDefinition/sample-patient');
+    });
+
+    it('should properly convert global profile for external profiles too', () => {
+      const igJSON = doGlobalProfileTest('us-core-patient', true);
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(0);
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+      expect(igJSON.global).toEqual([
+        {
+          type: 'Patient',
+          profile: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'
+        }
+      ]);
+    });
+
+    it('should log a warning and use reference as-is when a name/id cannot be resolved', () => {
+      const igJSON = doGlobalProfileTest('invalid-global-canonical-id', true);
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(1);
+      expect(loggerSpy.getLastMessage('warn')).toMatch(
+        'Cannot determine canonical for "invalid-global-canonical-id" referenced in sushi-config.yaml.'
+      );
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+      expect(igJSON.global).toEqual([
+        {
+          type: 'Patient',
+          profile: 'invalid-global-canonical-id'
+        }
+      ]);
+    });
+  });
+
   describe('#pages-folder-ig', () => {
     let pkg: Package;
     let exporter: IGExporter;
diff --git a/test/import/fixtures/example-config.yaml b/test/import/fixtures/example-config.yaml
index 0ad20fb..f1abd74 100644
--- a/test/import/fixtures/example-config.yaml
+++ b/test/import/fixtures/example-config.yaml
@@ -74,7 +74,8 @@ dependencies:
 # uses the type as the YAML key and the profile as its value. Since
 # FHIR does not explicitly disallow more than one profile per type,
 # neither do we; the value can be a single profile URL or an array
-# of profile URLs.
+# of profile URLs. If a value is an id or name, SUSHI will replace
+# it with the correct canonical when generating the IG JSON.
 global:
   Patient: http://example.org/fhir/StructureDefinition/my-patient-profile
   Encounter: http://example.org/fhir/StructureDefinition/my-encounter-profile
@@ -82,15 +83,19 @@ global:
 # The resources property corresponds to IG.definition.resource.
 # SUSHI can auto-generate all of the resource entries based on
 # the FSH definitions and/or information in any user-provided
-# JSON resource files. If the generated entries are not
+# JSON or XML resource files. If the generated entries are not
 # sufficient or complete, however, the author can add entries
 # here. If the reference matches a generated entry, it will
 # replace the generated entry. If it doesn't match any generated
 # entries, it will be added to the generated entries. The format
 # follows IG.definition.resource with the following differences:
-#   * use IG.definition.resource.reference.reference as the YAML key
+#   * use IG.definition.resource.reference.reference as the YAML key.
+#   * if the key is an id or name, SUSHI will replace it with the
+#     correct URL when generating the IG JSON.
 #   * specify "omit" to omit a FSH-generated resource from the
 #     resource list.
+#   * if the exampleCanonical is an id or name, SUSHI will replace
+#     it with the correct canonical when generating the IG JSON.
 #   * groupingId can be used, but top-level groups syntax may be a
 #     better option (see below).
 # The following are simple examples to demonstrate what this might
@@ -107,7 +112,9 @@ resources:
 # are provided by the templating framework, but if authors want to
 # use their own instead, they can use the mechanism below.  This will
 # create IG.definition.grouping entries and associate the individual
-# resource entries with the corresponding groupIds.
+# resource entries with the corresponding groupIds. If a resource
+# is specified by id or name, SUSHI will replace it with the correct
+# URL when generating the IG JSON.
 groups:
   GroupA:
     name: Group A
@@ -245,7 +252,7 @@ instanceOptions:
   # - standalone-only: Set meta.profile for only Instances of profiles where Usage is any value other than #inline
   setMetaProfile: 'always'
   # Determines for which types of Instances SUSHI will automatically set id
-  # if InstanceOf references a profile. Options are: 
+  # if InstanceOf references a profile. Options are:
   # - always: Set id for all Instances (default)
   # - standalone-only: Set id for only Instances where Usage is any value other than #inline
   setId: 'always'
diff --git a/test/utils/fixtures/init-config/default-config.yaml b/test/utils/fixtures/init-config/default-config.yaml
index 7d3ffd2..4ee7965 100644
--- a/test/utils/fixtures/init-config/default-config.yaml
+++ b/test/utils/fixtures/init-config/default-config.yaml
@@ -97,7 +97,8 @@ menu:
 # uses the type as the YAML key and the profile as its value. Since
 # FHIR does not explicitly disallow more than one profile per type,
 # neither do we; the value can be a single profile URL or an array
-# of profile URLs.
+# of profile URLs. If a value is an id or name, SUSHI will replace
+# it with the correct canonical when generating the IG JSON.
 #
 # global:
 #   Patient: http://example.org/fhir/StructureDefinition/my-patient-profile
@@ -113,9 +114,13 @@ menu:
 # replace the generated entry. If it doesn't match any generated
 # entries, it will be added to the generated entries. The format
 # follows IG.definition.resource with the following differences:
-#   * use IG.definition.resource.reference.reference as the YAML key
+#   * use IG.definition.resource.reference.reference as the YAML key.
+#   * if the key is an id or name, SUSHI will replace it with the
+#     correct URL when generating the IG JSON.
 #   * specify "omit" to omit a FSH-generated resource from the
 #     resource list.
+#   * if the exampleCanonical is an id or name, SUSHI will replace
+#     it with the correct canonical when generating the IG JSON.
 #   * groupingId can be used, but top-level groups syntax may be a
 #     better option (see below).
 # The following are simple examples to demonstrate what this might
@@ -134,7 +139,9 @@ menu:
 # are provided by the templating framework, but if authors want to
 # use their own instead, they can use the mechanism below.  This will
 # create IG.definition.grouping entries and associate the individual
-# resource entries with the corresponding groupIds.
+# resource entries with the corresponding groupIds. If a resource
+# is specified by id or name, SUSHI will replace it with the correct
+# URL when generating the IG JSON.
 #
 # groups:
 #   GroupA:
diff --git a/test/utils/fixtures/init-config/user-input-config.yaml b/test/utils/fixtures/init-config/user-input-config.yaml
index e4a981d..ea45347 100644
--- a/test/utils/fixtures/init-config/user-input-config.yaml
+++ b/test/utils/fixtures/init-config/user-input-config.yaml
@@ -97,7 +97,8 @@ menu:
 # uses the type as the YAML key and the profile as its value. Since
 # FHIR does not explicitly disallow more than one profile per type,
 # neither do we; the value can be a single profile URL or an array
-# of profile URLs.
+# of profile URLs. If a value is an id or name, SUSHI will replace
+# it with the correct canonical when generating the IG JSON.
 #
 # global:
 #   Patient: http://example.org/fhir/StructureDefinition/my-patient-profile
@@ -113,9 +114,13 @@ menu:
 # replace the generated entry. If it doesn't match any generated
 # entries, it will be added to the generated entries. The format
 # follows IG.definition.resource with the following differences:
-#   * use IG.definition.resource.reference.reference as the YAML key
+#   * use IG.definition.resource.reference.reference as the YAML key.
+#   * if the key is an id or name, SUSHI will replace it with the
+#     correct URL when generating the IG JSON.
 #   * specify "omit" to omit a FSH-generated resource from the
 #     resource list.
+#   * if the exampleCanonical is an id or name, SUSHI will replace
+#     it with the correct canonical when generating the IG JSON.
 #   * groupingId can be used, but top-level groups syntax may be a
 #     better option (see below).
 # The following are simple examples to demonstrate what this might
@@ -134,7 +139,9 @@ menu:
 # are provided by the templating framework, but if authors want to
 # use their own instead, they can use the mechanism below.  This will
 # create IG.definition.grouping entries and associate the individual
-# resource entries with the corresponding groupIds.
+# resource entries with the corresponding groupIds. If a resource
+# is specified by id or name, SUSHI will replace it with the correct
+# URL when generating the IG JSON.
 #
 # groups:
 #   GroupA: