Repository: FHIR/sushi (ts)

Issue:
SUSHI chokes on JSON logical model instances that don't have resourceType/id
Technically, logical model instances are not required to have a `resourceType` or `id`, but if you include a JSON instance of a logical model in any of the `input/*` resource folders, SUSHI will try to load it and will emit an error when it can't find `resourceType`/`id`.

@lmckenzi has suggested an approach to detect which of these files can be safely ignored by looking at the resource listing in `sushi-config.yaml`. We should investigate further and implement as suggested and/or only _warn_ when `resourceType` can't be found (instead of _error_) since warnings can be suppressed.

See: https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/How.20do.20I.20get.20SUSHI.20to.20ignore.20a.20binary.20JSON.20logical.20instance.3F/near/407798801

Pull request description:
Support logical model examples without resourceType or id
Fixes #1406 

This PR supports including logical model examples that do not have a `resourceType` and/or an `id`. If the correct extension is added to the resource in the `sushi-config.yaml` file, then SUSHI will detect that and not log any warnings. This was based off the suggested approach from Zulip [here](https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/How.20do.20I.20get.20SUSHI.20to.20ignore.20a.20binary.20JSON.20logical.20instance.3F/near/407798801). I confirmed that this approach works with the IG Publisher, and it will properly process the resource.

One thing to note is that we document including logical models [here](https://fshschool.org/docs/sushi/tips/#instances-of-logical-models) and indicate that the extension `http://hl7.org/fhir/StructureDefinition/implementationguide-resource-format` should be used. However, that Zulip thread indicated the extension `http://hl7.org/fhir/tools/StructureDefinition/implementationguide-resource-format` should be used. I tested this with the publisher, and both extension URLs work the same within the publisher, so I think it is valid for SUSHI to support both as well. If we hear back that one URL is preferred over the other, we can make sure that the Tips and Tricks page in FSH School reflects the best practice.

I also updated the error that was logged when a `resourceType` or `id` was missing to be a warning. If an example without an `id` or `resourceType` is included but configured correctly with the extension, this will not be logged. If the resource isn't configured correctly, the IG Publisher will crash, so we _could_ keep it as an error, so I'm open to feedback here. My reasoning was that it seemed like the error should say something about configuring the resource type to resolve the error since it could be valid that `resourceType` or `id` was missing. Instead of including IGPublisher specific configuration in a SUSHI error message, I chose to just soften it to a warning. But again, I'm open to switching it back.

There's an example in the tests that shows this feature, but I've included a sample project here: [LogicalExamplesWithoutResourceTypeId.zip](https://github.com/FHIR/sushi/files/14030580/LogicalExamplesWithoutResourceTypeId.zip)

There is also a FSH School PR [here](https://github.com/FSHSchool/site/pull/86) that updates the Tips and Tricks to include how to configure an example without a `resourceType` and `id`.

Patch:
diff --git a/src/ig/IGExporter.ts b/src/ig/IGExporter.ts
index 4530e6f..7a70ec1 100644
--- a/src/ig/IGExporter.ts
+++ b/src/ig/IGExporter.ts
@@ -924,6 +924,10 @@ export class IGExporter {
    * analyzed when making changes to either.
    */
   private addPredefinedResources(): void {
+    const igResourceFormatExtensionUrls = [
+      'http://hl7.org/fhir/StructureDefinition/implementationguide-resource-format',
+      'http://hl7.org/fhir/tools/StructureDefinition/implementationguide-resource-format'
+    ];
     // Similar code for loading custom resources exists in load.ts loadCustomResources()
     const pathEnds = [
       'capabilities',
@@ -949,6 +953,11 @@ export class IGExporter {
       if (pathResourceDirectories) predefinedResourcePaths.push(...pathResourceDirectories);
     }
     const deeplyNestedFiles: string[] = [];
+    const configuredBinaryResources = (this.config.resources ?? []).filter(
+      resource =>
+        resource.reference?.reference?.startsWith('Binary/') &&
+        resource.extension?.some(e => igResourceFormatExtensionUrls.includes(e.url))
+    );
     for (const dirPath of predefinedResourcePaths) {
       if (existsSync(dirPath)) {
         const files = getFilesRecursive(dirPath);
@@ -962,8 +971,34 @@ export class IGExporter {
           }
           const resourceJSON: InstanceDefinition = this.fhirDefs.getPredefinedResource(file);
           if (resourceJSON) {
+            // For predefined examples of Logical Models, the user must provide an entry in config
+            // that specifies the reference as Binary/[id], the extension that specifies the resource format,
+            // and the exampleCanonical that references the LogicalModel the resource is an example of.
+            // In that case, we do not want to add our own entry for the predefined resource - we just
+            // want to use the resource entry from the sushi-config.yaml
+            // For predefined examples of Logical Models that do not have a resourceType or id,
+            // a Binary resource reference based on the file name can be used, based on Zulip:
+            // https://chat.fhir.org/#narrow/stream/215610-shorthand/topic/How.20do.20I.20get.20SUSHI.20to.20ignore.20a.20binary.20JSON.20logical.20instance.3F/near/407861211
+            const hasBinaryReference = configuredBinaryResources.some(
+              resource =>
+                (resource.reference?.reference === `Binary/${resourceJSON.id}` &&
+                  resource.exampleCanonical ===
+                    `${this.config.canonical}/StructureDefinition/${resourceJSON.resourceType}`) ||
+                resource.reference?.reference === `Binary/${path.parse(file).name}`
+            );
+
+            if (hasBinaryReference) {
+              continue;
+            }
+
             if (resourceJSON.resourceType == null || resourceJSON.id == null) {
-              logger.error(`Resource at ${file} must define resourceType and id.`);
+              logger.warn(
+                `Resource at ${file} is missing ${
+                  resourceJSON.resourceType == null ? 'resourceType' : ''
+                }${resourceJSON.resourceType == null && resourceJSON.id == null ? ' and ' : ''}${
+                  resourceJSON.id == null ? 'id' : ''
+                }.`
+              );
               continue;
             }
 
@@ -977,24 +1012,7 @@ export class IGExporter {
               resource => resource.reference?.reference == referenceKey
             );
 
-            // For predefined examples of Logical Models, the user must provide an entry in config
-            // that specifies the reference as Binary/[id], the extension that specifies the resource format,
-            // and the exampleCanonical that references the LogicalModel the resource is an example of.
-            // In that case, we do not want to add our own entry for the predefined resource - we just
-            // want to use the resource entry from the sushi-config.yaml
-            const hasBinaryExampleReference = (this.config.resources ?? []).some(
-              resource =>
-                resource.reference?.reference === `Binary/${resourceJSON.id}` &&
-                resource.exampleCanonical ===
-                  `${this.config.canonical}/StructureDefinition/${resourceJSON.resourceType}` &&
-                resource.extension?.some(
-                  e =>
-                    e.url ===
-                    'http://hl7.org/fhir/StructureDefinition/implementationguide-resource-format'
-                )
-            );
-
-            if (configResource?.omit !== true && !hasBinaryExampleReference) {
+            if (configResource?.omit !== true) {
               const existingIndex = this.ig.definition.resource.findIndex(
                 r => r.reference.reference === referenceKey
               );


Test patch:
diff --git a/test/ig/IGExporter.IG.test.ts b/test/ig/IGExporter.IG.test.ts
index 9220187..10be801 100644
--- a/test/ig/IGExporter.IG.test.ts
+++ b/test/ig/IGExporter.IG.test.ts
@@ -2885,10 +2885,17 @@ describe('IGExporter', () => {
       ]);
     });
 
-    it('should log an error for input files missing resourceType or id', () => {
+    it('should log a warning for input files missing resourceType or id', () => {
       exporter.export(tempOut);
-      expect(loggerSpy.getFirstMessage('error')).toMatch(
-        /.*InvalidPatient.json must define resourceType and id/
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(3);
+      expect(loggerSpy.getMessageAtIndex(0, 'warn')).toMatch(
+        /.*InvalidPatient\.json is missing id/
+      );
+      expect(loggerSpy.getMessageAtIndex(1, 'warn')).toMatch(
+        /.*InvalidPatient2\.json is missing resourceType/
+      );
+      expect(loggerSpy.getMessageAtIndex(2, 'warn')).toMatch(
+        /.*InvalidPatient3\.json is missing resourceType and id/
       );
     });
   });
@@ -3089,6 +3096,96 @@ describe('IGExporter', () => {
     });
   });
 
+  describe('#customized-ig-with-binary-example', () => {
+    // An IG with one Binary example without resourceType or id
+    let pkg: Package;
+    let exporter: IGExporter;
+    let tempOut: string;
+    let fixtures: string;
+    let config: Configuration;
+    let defs: FHIRDefinitions;
+
+    beforeAll(() => {
+      defs = new FHIRDefinitions();
+      fixtures = path.join(__dirname, 'fixtures', 'customized-ig-with-binary-example');
+      loadCustomResources(path.join(fixtures, 'input'), undefined, undefined, defs);
+    });
+
+    beforeEach(() => {
+      loggerSpy.reset();
+      tempOut = temp.mkdirSync('sushi-test');
+      config = cloneDeep(minimalConfig);
+      config.resources = [
+        {
+          reference: {
+            reference: 'Binary/MissingResourceTypeAndId'
+          },
+          extension: [
+            {
+              url: 'http://hl7.org/fhir/StructureDefinition/implementationguide-resource-format',
+              valueCode: 'application/json'
+            }
+          ]
+        },
+        {
+          reference: {
+            reference: 'Binary/MissingId'
+          },
+          extension: [
+            {
+              url: 'http://hl7.org/fhir/tools/StructureDefinition/implementationguide-resource-format',
+              valueCode: 'application/fhir+json'
+            }
+          ]
+        }
+      ];
+      pkg = new Package(config);
+      exporter = new IGExporter(pkg, defs, fixtures);
+    });
+
+    afterAll(() => {
+      temp.cleanupSync();
+    });
+
+    it('should allow binary resources that do not have resourceType or id but are specified correctly in config', () => {
+      exporter.export(tempOut);
+      const igPath = path.join(
+        tempOut,
+        'fsh-generated',
+        'resources',
+        'ImplementationGuide-fhir.us.minimal.json'
+      );
+      expect(fs.existsSync(igPath)).toBeTruthy();
+      const igContent: ImplementationGuide = fs.readJSONSync(igPath);
+      // resource entry is unchanged
+      expect(igContent.definition.resource).toHaveLength(2);
+      expect(igContent.definition.resource).toContainEqual({
+        reference: {
+          reference: 'Binary/MissingResourceTypeAndId'
+        },
+        extension: [
+          {
+            url: 'http://hl7.org/fhir/StructureDefinition/implementationguide-resource-format',
+            valueCode: 'application/json'
+          }
+        ]
+      });
+      expect(igContent.definition.resource).toContainEqual({
+        reference: {
+          reference: 'Binary/MissingId'
+        },
+        extension: [
+          {
+            url: 'http://hl7.org/fhir/tools/StructureDefinition/implementationguide-resource-format',
+            valueCode: 'application/fhir+json'
+          }
+        ]
+      });
+      expect(loggerSpy.getAllMessages('error')).toHaveLength(0);
+      expect(loggerSpy.getAllMessages('warn')).toHaveLength(0);
+    });
+  });
+
   describe('#ref-by-id-or-name', () => {
     let pkg: Package;
     let tempOut: string;
diff --git a/test/ig/fixtures/customized-ig-with-binary-example/input/examples/MissingId.json b/test/ig/fixtures/customized-ig-with-binary-example/input/examples/MissingId.json
new file mode 100644
index 0000000..abb9861
--- /dev/null
+++ b/test/ig/fixtures/customized-ig-with-binary-example/input/examples/MissingId.json
@@ -0,0 +1,4 @@
+{
+  "resourceType": "MyLogicalModel",
+  "important": "true"
+}
\ No newline at end of file
diff --git a/test/ig/fixtures/customized-ig-with-binary-example/input/examples/MissingResourceTypeAndId.json b/test/ig/fixtures/customized-ig-with-binary-example/input/examples/MissingResourceTypeAndId.json
new file mode 100644
index 0000000..91d3e84
--- /dev/null
+++ b/test/ig/fixtures/customized-ig-with-binary-example/input/examples/MissingResourceTypeAndId.json
@@ -0,0 +1,7 @@
+{
+  "information": {
+    "important": true,
+    "importance": 10,
+    "description": "very important information used to demonstrate that JSON without resourceType and id can be loaded"
+  }
+}
\ No newline at end of file
diff --git a/test/ig/fixtures/customized-ig-with-resources/input/resources/InvalidPatient2.json b/test/ig/fixtures/customized-ig-with-resources/input/resources/InvalidPatient2.json
new file mode 100644
index 0000000..0bb4c30
--- /dev/null
+++ b/test/ig/fixtures/customized-ig-with-resources/input/resources/InvalidPatient2.json
@@ -0,0 +1,4 @@
+{
+  "id": "Patient",
+  "name": [{ "family": "Invalid" }]
+}
\ No newline at end of file
diff --git a/test/ig/fixtures/customized-ig-with-resources/input/resources/InvalidPatient3.json b/test/ig/fixtures/customized-ig-with-resources/input/resources/InvalidPatient3.json
new file mode 100644
index 0000000..0571613
--- /dev/null
+++ b/test/ig/fixtures/customized-ig-with-resources/input/resources/InvalidPatient3.json
@@ -0,0 +1,3 @@
+{
+  "name": [{ "family": "Invalid" }]
+}
\ No newline at end of file