Repository: FHIR/sushi
Pull request #211 (2020-02-12 13:53:03)

Write resources out to type-based folders
Instead of writing resources to "resources", write them to:
- profile
- extensions
- vocabulary
- examples
according to their type and purpose.

This also consolidates the code that actually writes the
files into a single location (instead of writing them in
app.js sometimes and IGExporter other times.


Patch:
diff --git a/src/app.ts b/src/app.ts
index c93350e..cde4383 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -94,15 +94,32 @@ async function app() {
 
   fs.ensureDirSync(program.out);
 
-  const resourceDir = path.join(program.out, 'input', 'resources');
-  fs.ensureDirSync(resourceDir);
-
   fs.writeFileSync(
     path.join(program.out, 'package.json'),
     JSON.stringify(outPackage.config, null, 2),
     'utf8'
   );
 
+  logger.info('Exporting FHIR resources as JSON...');
+  let count = 0;
+  const writeResources = (
+    folder: string,
+    resources: { getFileName: () => string; toJSON: () => any }[]
+  ) => {
+    const exportDir = path.join(program.out, 'input', folder);
+    resources.forEach(resource => {
+      fs.outputJSONSync(path.join(exportDir, resource.getFileName()), resource.toJSON(), {
+        spaces: 2
+      });
+      count++;
+    });
+  };
+  writeResources('profiles', outPackage.profiles);
+  writeResources('extensions', outPackage.extensions);
+  writeResources('vocabulary', [...outPackage.valueSets, ...outPackage.codeSystems]);
+  writeResources('examples', outPackage.instances);
+  logger.info(`Exported ${count} FHIR resources as JSON.`);
+
   // If ig-data exists, generate an IG, otherwise, generate resources only
   const igDataPath = path.resolve(input, 'ig-data');
   if (fs.existsSync(igDataPath)) {
@@ -110,24 +127,6 @@ async function app() {
     const igExporter = new IGExporter(outPackage, defs, igDataPath);
     igExporter.export(program.out);
     logger.info('Built FHIR Implementation Guide; ready for IG Publisher.');
-  } else {
-    logger.info('Exporting FHIR resources as JSON...');
-    let count = 0;
-    for (const sd of [
-      ...outPackage.profiles,
-      ...outPackage.extensions,
-      ...outPackage.instances,
-      ...outPackage.valueSets,
-      ...outPackage.codeSystems
-    ]) {
-      fs.writeFileSync(
-        path.join(resourceDir, sd.getFileName()),
-        JSON.stringify(sd.toJSON(), null, 2),
-        'utf8'
-      );
-      count++;
-    }
-    logger.info(`Exported ${count} FHIR resources as JSON.`);
   }
 
   logger.info(`
diff --git a/src/fhirtypes/CodeSystem.ts b/src/fhirtypes/CodeSystem.ts
index 7ad048f..a769af6 100644
--- a/src/fhirtypes/CodeSystem.ts
+++ b/src/fhirtypes/CodeSystem.ts
@@ -1,3 +1,4 @@
+import { cloneDeep } from 'lodash';
 import { Meta } from './specialTypes';
 import { Extension } from '../fshtypes';
 import { Narrative, Resource, Identifier, CodeableConcept, Coding } from './dataTypes';
@@ -10,6 +11,7 @@ import { applyMixins } from '../utils';
  * @see {@link https://www.hl7.org/fhir/codesystem.html}
  */
 export class CodeSystem {
+  readonly resourceType = 'CodeSystem';
   // id?: FHIRId; // provided by HasId mixin
   meta?: Meta;
   implicitRules?: string;
@@ -59,8 +61,7 @@ export class CodeSystem {
    */
   toJSON(): any {
     return {
-      resourceType: 'CodeSystem',
-      ...this
+      ...cloneDeep(this)
     };
   }
 }
diff --git a/src/fhirtypes/StructureDefinition.ts b/src/fhirtypes/StructureDefinition.ts
index 822babd..41b9153 100644
--- a/src/fhirtypes/StructureDefinition.ts
+++ b/src/fhirtypes/StructureDefinition.ts
@@ -24,6 +24,7 @@ import { applyMixins } from '../utils';
  * @see {@link http://hl7.org/fhir/R4/structuredefinition.html|FHIR StructureDefinition}
  */
 export class StructureDefinition {
+  readonly resourceType = 'StructureDefinition';
   // id: FHIRId; // provided by HasId mixin
   meta: Meta;
   implicitRules: string;
@@ -303,7 +304,7 @@ export class StructureDefinition {
    * @returns {any} the FHIR JSON representation of the StructureDefinition
    */
   toJSON(): any {
-    const j: LooseStructDefJSON = { resourceType: 'StructureDefinition' };
+    const j: LooseStructDefJSON = { resourceType: this.resourceType };
     // First handle properties that are just straight translations to JSON
     for (const prop of PROPS) {
       // @ts-ignore
diff --git a/src/fhirtypes/ValueSet.ts b/src/fhirtypes/ValueSet.ts
index 3563e0c..3637c46 100644
--- a/src/fhirtypes/ValueSet.ts
+++ b/src/fhirtypes/ValueSet.ts
@@ -14,6 +14,7 @@ import { applyMixins } from '../utils';
  */
 
 export class ValueSet {
+  readonly resourceType = 'ValueSet';
   // id: FHIRId; // provided by HasId mixin
   meta: Meta;
   implicitRules: string;
@@ -54,7 +55,6 @@ export class ValueSet {
    */
   toJSON(): any {
     return {
-      resourceType: 'ValueSet',
       ...cloneDeep(this)
     };
   }
diff --git a/src/ig/IGExporter.ts b/src/ig/IGExporter.ts
index b9cc850..b7889f0 100644
--- a/src/ig/IGExporter.ts
+++ b/src/ig/IGExporter.ts
@@ -10,7 +10,10 @@ import {
   ContactDetail,
   ImplementationGuide,
   ImplementationGuideDefinitionResource,
-  ImplementationGuideDefinitionPageGeneration
+  ImplementationGuideDefinitionPageGeneration,
+  StructureDefinition,
+  ValueSet,
+  CodeSystem
 } from '../fhirtypes';
 import { logger, Type } from '../utils';
 import { FHIRDefinitions } from '../fhirdefs';
@@ -39,7 +42,7 @@ export class IGExporter {
   export(outPath: string) {
     ensureDirSync(outPath);
     this.initIG();
-    this.addResources(outPath);
+    this.addResources();
     this.addStaticFiles(outPath);
     this.addIndex(outPath);
     this.addOtherPageContent(outPath);
@@ -271,65 +274,38 @@ export class IGExporter {
 
   /**
    * Add each of the resources from the package to the ImplementationGuide JSON file.
-   *
-   * @param igPath {string} - the path where the IG is exported to
    */
-  private addResources(igPath: string) {
-    logger.info('Exporting FHIR resources as JSON for Implementation Guide...');
-    let count = 0;
-    const sds = sortBy([...this.pkg.profiles, ...this.pkg.extensions], sd => sd.name);
-    sds.forEach(sd => {
-      const sdPath = path.join(igPath, 'input', 'resources', sd.getFileName());
-      outputJSONSync(sdPath, sd.toJSON(), { spaces: 2 });
+  private addResources() {
+    const resources: (StructureDefinition | ValueSet | CodeSystem)[] = [
+      ...sortBy(this.pkg.profiles, sd => sd.name),
+      ...sortBy(this.pkg.extensions, sd => sd.name),
+      ...sortBy(this.pkg.valueSets, valueSet => valueSet.name),
+      ...sortBy(this.pkg.codeSystems, codeSystem => codeSystem.name)
+    ];
+    resources.forEach(r => {
       this.ig.definition.resource.push({
-        reference: { reference: `StructureDefinition/${sd.id}` },
-        name: sd.title ?? sd.name,
-        description: sd.description,
+        reference: { reference: `${r.resourceType}/${r.id}` },
+        name: r.title ?? r.name,
+        description: r.description,
         exampleBoolean: false
       });
-      count++;
     });
-    sortBy(this.pkg.instances, instance => instance.id ?? instance.instanceName).forEach(
-      instance => {
-        const instancePath = path.join(igPath, 'input', 'resources', instance.getFileName());
-        outputJSONSync(instancePath, instance.toJSON(), { spaces: 2 });
-        const resource: ImplementationGuideDefinitionResource = {
-          reference: {
-            reference: `${instance.resourceType}/${instance.id ?? instance.instanceName}`
-          },
-          name: instance.getFileName().slice(0, -5) // Slice off the .json of the file name
-        };
-        const exampleUrl = instance.meta?.profile?.find(url => this.pkg.fish(url, Type.Profile));
-        if (exampleUrl) {
-          resource.exampleCanonical = exampleUrl;
-        } else {
-          resource.exampleBoolean = true;
-        }
-        this.ig.definition.resource.push(resource);
-        count++;
+    const examples = sortBy(this.pkg.instances, instance => instance.id ?? instance.instanceName);
+    examples.forEach(example => {
+      const resource: ImplementationGuideDefinitionResource = {
+        reference: {
+          reference: `${example.resourceType}/${example.id ?? example.instanceName}`
+        },
+        name: example.getFileName().slice(0, -5) // Slice off the .json of the file name
+      };
+      const exampleUrl = example.meta?.profile?.find(url => this.pkg.fish(url, Type.Profile));
+      if (exampleUrl) {
+        resource.exampleCanonical = exampleUrl;
+      } else {
+        resource.exampleBoolean = true;
       }
-    );
-    sortBy(this.pkg.valueSets, valueSet => valueSet.name).forEach(valueSet => {
-      const valueSetPath = path.join(igPath, 'input', 'resources', valueSet.getFileName());
-      outputJSONSync(valueSetPath, valueSet.toJSON(), { spaces: 2 });
-      this.ig.definition.resource.push({
-        reference: { reference: `ValueSet/${valueSet.id}` },
-        name: valueSet.title ?? valueSet.name,
-        description: valueSet.description
-      });
-      count++;
-    });
-    sortBy(this.pkg.codeSystems, codeSystem => codeSystem.name).forEach(codeSystem => {
-      const codeSystemPath = path.join(igPath, 'input', 'resources', codeSystem.getFileName());
-      outputJSONSync(codeSystemPath, codeSystem.toJSON(), { spaces: 2 });
-      this.ig.definition.resource.push({
-        reference: { reference: `CodeSystem/${codeSystem.id}` },
-        name: codeSystem.title ?? codeSystem.name,
-        description: codeSystem.description
-      });
-      count++;
+      this.ig.definition.resource.push(resource);
     });
-    logger.info(`Exported ${count} FHIR resources as JSON for Implementation Guide.`);
   }
 
   /**


Test patch:
diff --git a/test/export/CodeSystemExporter.test.ts b/test/export/CodeSystemExporter.test.ts
index 64f56af..54f0b08 100644
--- a/test/export/CodeSystemExporter.test.ts
+++ b/test/export/CodeSystemExporter.test.ts
@@ -45,6 +45,7 @@ describe('CodeSystemExporter', () => {
     const exported = exporter.export().codeSystems;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
       name: 'MyCodeSystem',
       id: 'MyCodeSystem',
       status: 'active',
@@ -63,6 +64,7 @@ describe('CodeSystemExporter', () => {
     const exported = exporter.export().codeSystems;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
       name: 'MyCodeSystem',
       id: 'CodeSystem1',
       status: 'active',
@@ -92,6 +94,7 @@ describe('CodeSystemExporter', () => {
     const exported = exporter.export().codeSystems;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
       name: 'MyCodeSystem',
       id: 'MyCodeSystem',
       status: 'active',
@@ -115,6 +118,7 @@ describe('CodeSystemExporter', () => {
     const exported = exporter.export().codeSystems;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
       name: 'MyCodeSystem',
       id: 'MyCodeSystem',
       status: 'active',
@@ -172,6 +176,7 @@ describe('CodeSystemExporter', () => {
     const exported = exporter.export().codeSystems;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
       id: 'CaretCodeSystem',
       name: 'CaretCodeSystem',
       content: 'complete',
@@ -192,6 +197,7 @@ describe('CodeSystemExporter', () => {
     const exported = exporter.export().codeSystems;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'CodeSystem',
       id: 'CaretCodeSystem',
       name: 'CaretCodeSystem',
       content: 'complete',
diff --git a/test/export/ValueSetExporter.test.ts b/test/export/ValueSetExporter.test.ts
index 10143e6..9a74865 100644
--- a/test/export/ValueSetExporter.test.ts
+++ b/test/export/ValueSetExporter.test.ts
@@ -51,6 +51,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       name: 'BreakfastVS',
       id: 'BreakfastVS',
       status: 'active',
@@ -76,6 +77,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       name: 'BreakfastVS',
       id: 'BreakfastVS',
       status: 'active',
@@ -129,6 +131,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       name: 'DinnerVS',
       id: 'DinnerVS',
       status: 'active',
@@ -152,6 +155,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       name: 'DinnerVS',
       id: 'DinnerVS',
       status: 'active',
@@ -177,6 +181,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       id: 'DinnerVS',
       name: 'DinnerVS',
       url: 'http://example.com/ValueSet/DinnerVS',
@@ -212,6 +217,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(3);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       id: 'DinnerVS',
       name: 'DinnerVS',
       url: 'http://example.com/ValueSet/DinnerVS',
@@ -246,6 +252,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       id: 'DinnerVS',
       name: 'DinnerVS',
       url: 'http://example.com/ValueSet/DinnerVS',
@@ -280,6 +287,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       id: 'BreakfastVS',
       name: 'BreakfastVS',
       url: 'http://example.com/ValueSet/BreakfastVS',
@@ -316,6 +324,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       id: 'BreakfastVS',
       name: 'BreakfastVS',
       url: 'http://example.com/ValueSet/BreakfastVS',
@@ -352,6 +361,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       id: 'BreakfastVS',
       name: 'BreakfastVS',
       url: 'http://example.com/ValueSet/BreakfastVS',
@@ -392,6 +402,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       id: 'DinnerVS',
       name: 'DinnerVS',
       url: 'http://example.com/ValueSet/DinnerVS',
@@ -446,6 +457,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       id: 'DinnerVS',
       name: 'DinnerVS',
       url: 'http://example.com/ValueSet/DinnerVS',
@@ -465,6 +477,7 @@ describe('ValueSetExporter', () => {
     const exported = exporter.export().valueSets;
     expect(exported.length).toBe(1);
     expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
       id: 'DinnerVS',
       name: 'DinnerVS',
       url: 'http://example.com/ValueSet/DinnerVS',
diff --git a/test/ig/IGExporter.test.ts b/test/ig/IGExporter.test.ts
index 83545f5..b869802 100644
--- a/test/ig/IGExporter.test.ts
+++ b/test/ig/IGExporter.test.ts
@@ -28,21 +28,24 @@ describe('IGExporter', () => {
       const fixtures = path.join(__dirname, 'fixtures', 'simple-ig');
       const config: Config = fs.readJSONSync(path.join(fixtures, 'package.json'));
       pkg = new Package(config);
-      const resources = path.join(fixtures, 'resources');
-      const instances = path.join(fixtures, 'instances');
-      fs.readdirSync(resources).forEach(f => {
+      const profiles = path.join(fixtures, 'profiles');
+      fs.readdirSync(profiles).forEach(f => {
         if (f.endsWith('.json')) {
-          const sd = StructureDefinition.fromJSON(fs.readJSONSync(path.join(resources, f)));
-          if (sd.type === 'Extension') {
-            pkg.extensions.push(sd);
-          } else {
-            pkg.profiles.push(sd);
-          }
+          const sd = StructureDefinition.fromJSON(fs.readJSONSync(path.join(profiles, f)));
+          pkg.profiles.push(sd);
         }
       });
-      fs.readdirSync(instances).forEach(f => {
+      const extensions = path.join(fixtures, 'extensions');
+      fs.readdirSync(extensions).forEach(f => {
         if (f.endsWith('.json')) {
-          const instanceDef = InstanceDefinition.fromJSON(fs.readJSONSync(path.join(instances, f)));
+          const sd = StructureDefinition.fromJSON(fs.readJSONSync(path.join(extensions, f)));
+          pkg.extensions.push(sd);
+        }
+      });
+      const examples = path.join(fixtures, 'examples');
+      fs.readdirSync(examples).forEach(f => {
+        if (f.endsWith('.json')) {
+          const instanceDef = InstanceDefinition.fromJSON(fs.readJSONSync(path.join(examples, f)));
           pkg.instances.push(instanceDef);
         }
       });
@@ -74,34 +77,6 @@ describe('IGExporter', () => {
       expect(fs.existsSync(path.join(tempOut, 'input', 'includes', 'menu.xml'))).toBeTruthy();
     });
 
-    it('should copy over the resource files', () => {
-      const resourcesPath = path.join(tempOut, 'input', 'resources');
-      expect(fs.readdirSync(resourcesPath)).toHaveLength(6);
-      const ids = [
-        'sample-observation',
-        'sample-patient',
-        'sample-value-extension',
-        'sample-complex-extension'
-      ];
-
-      // StructureDefinitions copied
-      ids.forEach(id => {
-        const resourcePath = path.join(resourcesPath, `StructureDefinition-${id}.json`);
-        expect(fs.existsSync(resourcePath)).toBeTruthy();
-        expect(fs.readJSONSync(resourcePath).id).toEqual(id);
-      });
-
-      // Instances copied
-      const instancePath = path.join(resourcesPath, 'Patient-example.json');
-      expect(fs.existsSync(instancePath)).toBeTruthy();
-      expect(fs.readJSONSync(instancePath).id).toEqual('example');
-
-      // Code Systems copied
-      const codeSystemPath = path.join(resourcesPath, 'CodeSystem-sample-code-system.json');
-      expect(fs.existsSync(codeSystemPath)).toBeTruthy();
-      expect(fs.readJSONSync(codeSystemPath).id).toEqual('sample-code-system');
-    });
-
     it('should generate an ig.ini with the correct values based on the package.json', () => {
       const iniPath = path.join(tempOut, 'ig.ini');
       expect(fs.existsSync(iniPath)).toBeTruthy();
@@ -161,15 +136,6 @@ describe('IGExporter', () => {
         ],
         definition: {
           resource: [
-            {
-              reference: {
-                reference: 'StructureDefinition/sample-complex-extension'
-              },
-              name: 'SampleComplexExtension',
-              description:
-                'Base StructureDefinition for Extension Type: Optional Extension Element - found in all resources.',
-              exampleBoolean: false
-            },
             {
               reference: {
                 reference: 'StructureDefinition/sample-observation'
@@ -190,26 +156,36 @@ describe('IGExporter', () => {
             },
             {
               reference: {
-                reference: 'StructureDefinition/sample-value-extension'
+                reference: 'StructureDefinition/sample-complex-extension'
               },
-              name: 'SampleValueExtension',
+              name: 'SampleComplexExtension',
               description:
                 'Base StructureDefinition for Extension Type: Optional Extension Element - found in all resources.',
               exampleBoolean: false
             },
             {
               reference: {
-                reference: 'Patient/example'
+                reference: 'StructureDefinition/sample-value-extension'
               },
-              name: 'Patient-example',
-              exampleBoolean: true
+              name: 'SampleValueExtension',
+              description:
+                'Base StructureDefinition for Extension Type: Optional Extension Element - found in all resources.',
+              exampleBoolean: false
             },
             {
               reference: {
                 reference: 'CodeSystem/sample-code-system'
               },
               name: 'SampleCodeSystem',
-              description: 'A code system description'
+              description: 'A code system description',
+              exampleBoolean: false
+            },
+            {
+              reference: {
+                reference: 'Patient/example'
+              },
+              name: 'Patient-example',
+              exampleBoolean: true
             }
           ],
           page: {