Repository: FHIR/sushi (ts)

Issue:
Improve the message when sushi detects files in deep nested folders
Sushi seems a bit harsh when it detects that there are resources in nested folders:
> The following files were not added to the ImplementationGuide JSON because they are nested too deep. While SUSHI supports these paths, the IG Publisher does not. To fix this, move these files so they are directly under a supported input folder (e.g., input/resources, input/profiles, etc.)

In fact, the IG can support any folder in any depth, as long as it is added as an IG parameter (path-resource). The templates add a few reference folders (input/resources, input/vocabulary, ...) but the author can add more also with sushi-config.

To accommodate for this, a better message is proposed below: 

> The following files were not added to the ImplementationGuide JSON because they are not in one of the supported input folders or are nested too deep in one of those folders. While SUSHI automatically supports resources in sub-folders, the IG Publisher does not, unless it is specifically told to do so - via the template or an IG parameter. To fix any issues you may encounter due to this, move these files so they are directly under a supported input folder (e.g., input/resources, input/profiles, etc.) or adjust your IG parameters or template accordingly.

Additionally, of course, if sushi-config has the path-resources parameter set, sushi should look into that path as well and not warn about those resources found there.




Pull request:
path-resource parameter support for Predefined Resources
Fixes #986, making It so that the `path-resource` parameter in `sushi-config.yaml` or an included ImplementationGuide resource are recognized, and resources in those listed directories are added as predefinedResources. Running sushi on the included FSH project should show this in action, as the config file includes a `path-resource` parameter that causes the extension included in `input/predefined-resource` to be included as a predefined resource as sushi runs.


[PathResourceTest.zip](https://github.com/FHIR/sushi/files/7995282/PathResourceTest.zip)
 

Patch:
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..3cacc0b
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1,1 @@
+12
\ No newline at end of file
diff --git a/src/app.ts b/src/app.ts
index ac48edd..7074704 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -152,7 +152,7 @@ async function app() {
   await loadExternalDependencies(defs, config);
 
   // Load custom resources. In current tank configuration (input/fsh), resources will be in input/
-  loadCustomResources(path.join(input, '..'), defs);
+  loadCustomResources(path.join(input, '..'), originalInput, config.parameters, defs);
 
   // Check for StructureDefinition
   const structDef = defs.fishForFHIR('StructureDefinition', Type.Resource);
diff --git a/src/fhirdefs/load.ts b/src/fhirdefs/load.ts
index d17a443..d163c12 100644
--- a/src/fhirdefs/load.ts
+++ b/src/fhirdefs/load.ts
@@ -9,6 +9,7 @@ import junk from 'junk';
 import temp from 'temp';
 import { logger, getFilesRecursive } from '../utils';
 import { Fhir as FHIRConverter } from 'fhir/fhir';
+import { ImplementationGuideDefinitionParameter } from '../fhirtypes';
 
 /**
  * Loads a dependency from user FHIR cache or from online
@@ -202,9 +203,17 @@ export function cleanCachedPackage(packageDirectory: string): void {
 /**
  * Loads custom resources defined in resourceDir into FHIRDefs
  * @param {string} resourceDir - The path to the directory containing the resource subdirs
+ * @param {string} projectDir - User's specified project directory
+ * @param {ImplementationGuideDefinitionParameter[]} configParameters - optional, an array of config parameters in which to
+ *    determine if there are additional resource paths for predefined resource
  * @param {FHIRDefinitions} defs - The FHIRDefinitions object to load definitions into
  */
-export function loadCustomResources(resourceDir: string, defs: FHIRDefinitions): void {
+export function loadCustomResources(
+  resourceDir: string,
+  projectDir: string = null,
+  configParameters: ImplementationGuideDefinitionParameter[] = null,
+  defs: FHIRDefinitions
+): void {
   // Similar code for loading custom resources exists in IGExporter.ts addPredefinedResources()
   const pathEnds = [
     'capabilities',
@@ -216,11 +225,20 @@ export function loadCustomResources(resourceDir: string, defs: FHIRDefinitions):
     'vocabulary',
     'examples'
   ];
+  const predefinedResourcePaths = pathEnds.map(pathEnd => path.join(resourceDir, pathEnd));
+  if (configParameters && projectDir) {
+    const pathResources = configParameters
+      ?.filter(parameter => parameter.value && parameter.code === 'path-resource')
+      .map(parameter => parameter.value);
+    const pathResourceDirectories = pathResources
+      .map(directoryPath => path.join(projectDir, directoryPath))
+      .filter(directoryPath => fs.existsSync(directoryPath));
+    if (pathResourceDirectories) predefinedResourcePaths.push(...pathResourceDirectories);
+  }
   const converter = new FHIRConverter();
   let invalidFileCount = 0;
-  for (const pathEnd of pathEnds) {
+  for (const dirPath of predefinedResourcePaths) {
     let foundSpreadsheets = false;
-    const dirPath = path.join(resourceDir, pathEnd);
     if (fs.existsSync(dirPath)) {
       const files = getFilesRecursive(dirPath);
       for (const file of files) {
@@ -257,7 +275,7 @@ export function loadCustomResources(resourceDir: string, defs: FHIRDefinitions):
         // All resources are added to the predefined map, so that this map can later be used to
         // access predefined resources in the IG Exporter
         defs.addPredefinedResource(file, resourceJSON);
-        if (pathEnd !== 'examples') {
+        if (path.basename(dirPath) !== 'examples') {
           // add() will only add resources of resourceType:
           // StructureDefinition, ValueSet, CodeSystem, or ImplementationGuide
           defs.add(resourceJSON);
diff --git a/src/import/loadConfigurationFromIgResource.ts b/src/import/loadConfigurationFromIgResource.ts
index 786f6cb..4b88028 100644
--- a/src/import/loadConfigurationFromIgResource.ts
+++ b/src/import/loadConfigurationFromIgResource.ts
@@ -64,6 +64,7 @@ export function loadConfigurationFromIgResource(igRoot: string): Configuration |
       version: igResource.version,
       fhirVersion: igResource.fhirVersion ?? [],
       dependencies: igResource.dependsOn?.filter(dep => dep.packageId && dep.version),
+      parameters: igResource.definition?.parameter ?? [],
       FSHOnly: true
     };
     config.dependencies?.forEach(dep => {


Test patch:
diff --git a/test/fhirdefs/fixtures/customized-ig-with-resources/input/path-resource-test/StructureDefinition-MyObservation.json b/test/fhirdefs/fixtures/customized-ig-with-resources/input/path-resource-test/StructureDefinition-MyObservation.json
new file mode 100644
index 0000000..74344e5
--- /dev/null
+++ b/test/fhirdefs/fixtures/customized-ig-with-resources/input/path-resource-test/StructureDefinition-MyObservation.json
@@ -0,0 +1,6 @@
+{
+  "resourceType": "StructureDefinition",
+  "id": "MyObservation",
+  "kind": "resource",
+  "derivation": "constraint"
+}
\ No newline at end of file
diff --git a/test/fhirdefs/load.test.ts b/test/fhirdefs/load.test.ts
index 0a56fac..f45d989 100644
--- a/test/fhirdefs/load.test.ts
+++ b/test/fhirdefs/load.test.ts
@@ -6,6 +6,7 @@ import {
   loadSupplementalFHIRPackage
 } from '../../src/fhirdefs/load';
 import { FHIRDefinitions } from '../../src/fhirdefs/FHIRDefinitions';
+import { ImplementationGuideDefinitionParameter } from '../../src/fhirtypes';
 import { Type } from '../../src/utils';
 import { PackageLoadError } from '../../src/errors';
 import { TestFisher, loggerSpy } from '../testhelpers';
@@ -430,7 +431,11 @@ describe('#loadCustomResources', () => {
     loggerSpy.reset();
     defs = new FHIRDefinitions();
     pathToInput = path.join(__dirname, 'fixtures', 'customized-ig-with-resources', 'input');
-    loadCustomResources(pathToInput, defs);
+    const configParamater: ImplementationGuideDefinitionParameter = {
+      code: 'path-resource',
+      value: 'path-resource-test'
+    };
+    loadCustomResources(pathToInput, pathToInput, [configParamater], defs);
   });
 
   it('should load custom JSON and XML resources', () => {
@@ -438,8 +443,9 @@ describe('#loadCustomResources', () => {
     const profiles = defs.allProfiles();
     const valueSets = defs.allValueSets();
     const extensions = defs.allExtensions();
-    expect(profiles).toHaveLength(2);
+    expect(profiles).toHaveLength(3);
     expect(profiles[0].id).toBe('MyPatient');
+    expect(profiles[2].id).toBe('MyObservation');
     expect(valueSets).toHaveLength(1);
     expect(valueSets[0].id).toBe('MyVS');
     // Each extension has 3 entries, one for url, one for id, and one for name
@@ -466,6 +472,11 @@ describe('#loadCustomResources', () => {
     expect(
       defs.getPredefinedResource(path.join(pathToInput, 'examples', 'Patient-MyPatient.json')).id
     ).toBe('MyPatient');
+    expect(
+      defs.getPredefinedResource(
+        path.join(pathToInput, 'path-resource-test', 'StructureDefinition-MyObservation.json')
+      ).id
+    ).toBe('MyObservation');
     expect(
       defs.getPredefinedResource(
         path.join(pathToInput, 'examples', 'Binary-LogicalModelExample.json')
diff --git a/test/ig/IGExporter.IG.test.ts b/test/ig/IGExporter.IG.test.ts
index 48b3e9f..f604d9d 100644
--- a/test/ig/IGExporter.IG.test.ts
+++ b/test/ig/IGExporter.IG.test.ts
@@ -1220,7 +1220,7 @@ describe('IGExporter', () => {
       defs = new FHIRDefinitions();
       loadFromPath(path.join(__dirname, '..', 'testhelpers', 'testdefs'), 'r4-definitions', defs);
       fixtures = path.join(__dirname, 'fixtures', 'customized-ig-with-resources');
-      loadCustomResources(path.join(fixtures, 'input'), defs);
+      loadCustomResources(path.join(fixtures, 'input'), undefined, undefined, defs);
     });
 
     beforeEach(() => {
@@ -1605,7 +1605,7 @@ describe('IGExporter', () => {
       defs = new FHIRDefinitions();
       loadFromPath(path.join(__dirname, '..', 'testhelpers', 'testdefs'), 'r4-definitions', defs);
       fixtures = path.join(__dirname, 'fixtures', 'customized-ig-with-nested-resources');
-      loadCustomResources(path.join(fixtures, 'input'), defs);
+      loadCustomResources(path.join(fixtures, 'input'), undefined, undefined, defs);
     });
 
     beforeEach(() => {
@@ -1671,7 +1671,7 @@ describe('IGExporter', () => {
     beforeAll(() => {
       defs = new FHIRDefinitions();
       fixtures = path.join(__dirname, 'fixtures', 'customized-ig-with-logical-model-example');
-      loadCustomResources(path.join(fixtures, 'input'), defs);
+      loadCustomResources(path.join(fixtures, 'input'), undefined, undefined, defs);
     });
 
     beforeEach(() => {
diff --git a/test/import/fixtures/ig-JSON/input/ImplementationGuide.json b/test/import/fixtures/ig-JSON/input/ImplementationGuide.json
index 28db46d..2275d22 100644
--- a/test/import/fixtures/ig-JSON/input/ImplementationGuide.json
+++ b/test/import/fixtures/ig-JSON/input/ImplementationGuide.json
@@ -2,13 +2,35 @@
   "resourceType": "ImplementationGuide",
   "url": "http://example.org/ImplementationGuide",
   "dependsOn": [
-    { "packageId": "foo.bar", "version": "1.2.3" },
-    { "packageId": "bar.foo", "version": "current" },
-    {"packageId": "just.packageId"},
-    {"version": "just.version"}
+    {
+      "packageId": "foo.bar",
+      "version": "1.2.3"
+    },
+    {
+      "packageId": "bar.foo",
+      "version": "current"
+    },
+    {
+      "packageId": "just.packageId"
+    },
+    {
+      "version": "just.version"
+    }
   ],
   "fhirVersion": [
     "4.0.1"
   ],
-  "version": "1.0.0"
-}
+  "version": "1.0.0",
+  "definition": {
+    "parameter": [
+      {
+        "code": "path-resource",
+        "value": "input/resources"
+      },
+      {
+        "code": "path-resource",
+        "value": "input/second-resource"
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/test/import/loadConfigurationFromIgResource.test.ts b/test/import/loadConfigurationFromIgResource.test.ts
index bd321ba..7707a40 100644
--- a/test/import/loadConfigurationFromIgResource.test.ts
+++ b/test/import/loadConfigurationFromIgResource.test.ts
@@ -13,7 +13,12 @@ describe('loadConfigurationFromIgResource', () => {
     // is ignored in favor of the one pointed to by ig.ini
     const inputPath = path.join(__dirname, 'fixtures', 'ig-JSON-with-ini');
     const config = loadConfigurationFromIgResource(inputPath);
-    expect(config).toEqual({ canonical: 'http://example.org', FSHOnly: true, fhirVersion: [] });
+    expect(config).toEqual({
+      canonical: 'http://example.org',
+      FSHOnly: true,
+      fhirVersion: [],
+      parameters: []
+    });
     expect(loggerSpy.getFirstMessage('info')).toMatch(
       new RegExp(
         `from ${escapeRegExp(path.join(inputPath, 'sneaky-input', 'ImplementationGuide.json'))}`
@@ -24,7 +29,12 @@ describe('loadConfigurationFromIgResource', () => {
   it('should extract a configuration with only a url', () => {
     const inputPath = path.join(__dirname, 'fixtures', 'ig-JSON-only-url');
     const config = loadConfigurationFromIgResource(inputPath);
-    expect(config).toEqual({ canonical: 'http://example.org', FSHOnly: true, fhirVersion: [] });
+    expect(config).toEqual({
+      canonical: 'http://example.org',
+      FSHOnly: true,
+      fhirVersion: [],
+      parameters: []
+    });
     expect(loggerSpy.getFirstMessage('info')).toMatch(
       new RegExp(`from ${escapeRegExp(path.join(inputPath, 'input', 'ImplementationGuide.json'))}`)
     );
@@ -41,7 +51,17 @@ describe('loadConfigurationFromIgResource', () => {
         { packageId: 'bar.foo', version: 'current' }
       ],
       fhirVersion: ['4.0.1'],
-      version: '1.0.0'
+      version: '1.0.0',
+      parameters: [
+        {
+          code: 'path-resource',
+          value: 'input/resources'
+        },
+        {
+          code: 'path-resource',
+          value: 'input/second-resource'
+        }
+      ]
     });
     expect(loggerSpy.getFirstMessage('info')).toMatch(
       new RegExp(`from ${escapeRegExp(path.join(inputPath, 'input', 'ImplementationGuide.json'))}`)
@@ -56,7 +76,13 @@ describe('loadConfigurationFromIgResource', () => {
     expect(loggerSpy.getMessageAtIndex(6, 'info')).toEqual(
       '  dependencies[1]: {"packageId":"bar.foo","version":"current"}'
     );
-    expect(loggerSpy.getMessageAtIndex(7, 'info')).toEqual('  FSHOnly: true');
+    expect(loggerSpy.getMessageAtIndex(7, 'info')).toEqual(
+      '  parameters[0]: {"code":"path-resource","value":"input/resources"}'
+    );
+    expect(loggerSpy.getMessageAtIndex(8, 'info')).toEqual(
+      '  parameters[1]: {"code":"path-resource","value":"input/second-resource"}'
+    );
+    expect(loggerSpy.getMessageAtIndex(9, 'info')).toEqual('  FSHOnly: true');
   });
 
   it('should convert uppercase package Ids to lowercase', () => {
@@ -71,7 +97,8 @@ describe('loadConfigurationFromIgResource', () => {
         { packageId: 'boo.far', version: '0.0.1' }
       ],
       fhirVersion: ['4.0.1'],
-      version: '1.0.0'
+      version: '1.0.0',
+      parameters: []
     });
     expect(loggerSpy.getFirstMessage('info')).toMatch(
       new RegExp(`from ${escapeRegExp(path.join(inputPath, 'input', 'ImplementationGuide.json'))}`)
@@ -102,7 +129,8 @@ describe('loadConfigurationFromIgResource', () => {
         { packageId: 'foo.bar', version: '1.2.3' },
         { packageId: 'bar.foo', version: 'current' }
       ],
-      fhirVersion: ['4.0.1']
+      fhirVersion: ['4.0.1'],
+      parameters: []
     });
     expect(loggerSpy.getFirstMessage('info')).toMatch(
       new RegExp(`from ${escapeRegExp(path.join(inputPath, 'input', 'ImplementationGuide.xml'))}`)
@@ -131,7 +159,8 @@ describe('loadConfigurationFromIgResource', () => {
         { packageId: 'bar.foo', version: 'current' },
         { packageId: 'boo.far', version: '0.0.1' }
       ],
-      fhirVersion: ['4.0.1']
+      fhirVersion: ['4.0.1'],
+      parameters: []
     });
     expect(loggerSpy.getFirstMessage('info')).toMatch(
       new RegExp(`from ${escapeRegExp(path.join(inputPath, 'input', 'ImplementationGuide.xml'))}`)
@@ -155,7 +184,12 @@ describe('loadConfigurationFromIgResource', () => {
   it('should find the ImplementationGuide JSON file even when other files are present', () => {
     const inputPath = path.join(__dirname, 'fixtures', 'ig-JSON-other-files');
     const config = loadConfigurationFromIgResource(inputPath);
-    expect(config).toEqual({ canonical: 'http://example.org', FSHOnly: true, fhirVersion: [] });
+    expect(config).toEqual({
+      canonical: 'http://example.org',
+      FSHOnly: true,
+      fhirVersion: [],
+      parameters: []
+    });
     expect(loggerSpy.getFirstMessage('info')).toMatch(
       new RegExp(`from ${escapeRegExp(path.join(inputPath, 'input', 'ImplementationGuide.json'))}`)
     );
diff --git a/test/utils/Processing.test.ts b/test/utils/Processing.test.ts
index 8e2ced5..7f7ecff 100644
--- a/test/utils/Processing.test.ts
+++ b/test/utils/Processing.test.ts
@@ -270,7 +270,8 @@ describe('Processing', () => {
       expect(config).toEqual({
         FSHOnly: true,
         canonical: 'http://example.org',
-        fhirVersion: ['4.0.1']
+        fhirVersion: ['4.0.1'],
+        parameters: []
       });
     });