Repository: FHIR/sushi
Pull request #599 (2020-09-15 12:29:57)

Add function which runs SUSHI
This adds a `fshToFhir` function, which can be called to convert FSH definitions to FHIR. The function also tracks errors and warnings that occur during the running of SUSHI. The function runs a simplified version of SUSHI, no `IGExporter` specific processing is done, only the conversion from FSH to FHIR. The function takes a set of options that can be used to change the configuration that SUSHI runs with. The options allow you to set the logging level, set dependencies, set the version, and set a canonical. If no options are given, SUSHI runs with default values for each of these things.

ngfreiter: Alright @cmoesel, all your comments should be addressed in 8c340ac.
ngfreiter: In 2548f31 I removed the exporting of snapshots. Also, instead of returning a `Package`, the `fshToFhir` function now returns a flat array of FHIR artifacts (with no snapshots). I felt like this was simpler, and makes it so that someone doesn't have to understand our `Package` class to use this function.

Patch:
diff --git a/src/index.ts b/src/index.ts
index b0e801a..f9e62d4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,3 +5,4 @@ export * as fshrules from './fshtypes/rules';
 export * as sushiExport from './export';
 export * as sushiImport from './import';
 export * as utils from './utils';
+export * as sushiClient from './run';
diff --git a/src/run/FshToFhir.ts b/src/run/FshToFhir.ts
new file mode 100644
index 0000000..0d502b0
--- /dev/null
+++ b/src/run/FshToFhir.ts
@@ -0,0 +1,96 @@
+import { RawFSH } from '../import';
+import { exportFHIR } from '../export';
+import { FHIRDefinitions } from '../fhirdefs';
+import { ImplementationGuideDependsOn } from '../fhirtypes';
+import {
+  fillTank,
+  loadExternalDependencies,
+  logger,
+  errorsAndWarnings,
+  ErrorsAndWarnings
+} from '../utils';
+
+export async function fshToFhir(
+  input: string | string[],
+  options: fshToFhirOptions = {}
+): Promise<{
+  fhir: any[];
+  errors: ErrorsAndWarnings['errors'];
+  warnings: ErrorsAndWarnings['warnings'];
+}> {
+  // track errors and warnings, and determine log level from options
+  errorsAndWarnings.shouldTrack = true;
+  if (options.logLevel == 'silent') {
+    logger.transports[0].silent = true;
+  } else if (options.logLevel != null) {
+    if (!isLevel(options.logLevel)) {
+      return {
+        fhir: null,
+        errors: [
+          {
+            message: `Invalid logLevel: ${options.logLevel}. Valid levels include: ${levels.join(
+              ', '
+            )}.`
+          }
+        ],
+        warnings: []
+      };
+    }
+    logger.level = options.logLevel;
+  }
+
+  // set up a config so that sushi can run
+  const config = {
+    canonical: options.canonical ?? 'http://example.org',
+    FSHOnly: true,
+    fhirVersion: ['4.0.1'],
+    dependencies: options.dependencies,
+    version: options.version
+  };
+
+  // load dependencies
+  const defs = new FHIRDefinitions();
+  await Promise.all(loadExternalDependencies(defs, config));
+
+  // load FSH text into memory
+  const rawFSHes: RawFSH[] = [];
+  if (Array.isArray(input)) {
+    input.forEach((input, i) => {
+      rawFSHes.push(new RawFSH(input, `Input_${i}`));
+    });
+  } else {
+    rawFSHes.push(new RawFSH(input));
+  }
+  const tank = fillTank(rawFSHes, config);
+
+  // process FSH text into FHIR
+  const outPackage = exportFHIR(tank, defs);
+  const fhir: any[] = [];
+  (['profiles', 'extensions', 'instances', 'valueSets', 'codeSystems'] as const).forEach(
+    artifactType => {
+      outPackage[artifactType].forEach((artifact: { toJSON: (snapshot: boolean) => any }) => {
+        fhir.push(artifact.toJSON(false));
+      });
+    }
+  );
+
+  return {
+    fhir,
+    errors: errorsAndWarnings.errors,
+    warnings: errorsAndWarnings.warnings
+  };
+}
+
+type fshToFhirOptions = {
+  canonical?: string;
+  version?: string;
+  dependencies?: ImplementationGuideDependsOn[];
+  logLevel?: Level;
+};
+
+// Winston levels: https://github.com/winstonjs/winston#logging-levels plus a silent option
+const levels = ['silly', 'debug', 'verbose', 'http', 'info', 'warn', 'error', 'silent'] as const;
+type Level = typeof levels[number];
+function isLevel(level: string): level is Level {
+  return levels.includes(level as Level);
+}
diff --git a/src/run/index.ts b/src/run/index.ts
new file mode 100644
index 0000000..7e6265f
--- /dev/null
+++ b/src/run/index.ts
@@ -0,0 +1,1 @@
+export * from './FshToFhir';
diff --git a/src/utils/FSHLogger.ts b/src/utils/FSHLogger.ts
index f6fccd8..59cbdf5 100644
--- a/src/utils/FSHLogger.ts
+++ b/src/utils/FSHLogger.ts
@@ -50,6 +50,26 @@ const incrementCounts = format(info => {
   return info;
 });
 
+const trackErrorsAndWarnings = format(info => {
+  if (!errorsAndWarnings.shouldTrack) {
+    return info;
+  }
+  if (info.level === 'error') {
+    errorsAndWarnings.errors.push({
+      message: info.message,
+      location: info.location,
+      input: info.file
+    });
+  } else if (info.level === 'warn') {
+    errorsAndWarnings.warnings.push({
+      message: info.message,
+      location: info.location,
+      input: info.file
+    });
+  }
+  return info;
+});
+
 const printer = printf(info => {
   let level;
   switch (info.level) {
@@ -73,7 +93,7 @@ const printer = printf(info => {
 });
 
 export const logger = createLogger({
-  format: combine(incrementCounts(), withLocation(), printer),
+  format: combine(incrementCounts(), trackErrorsAndWarnings(), withLocation(), printer),
   transports: [new transports.Console()]
 });
 
@@ -92,3 +112,17 @@ class LoggerStats {
 }
 
 export const stats = new LoggerStats();
+
+export class ErrorsAndWarnings {
+  public errors: { message: string; location?: string; input?: string }[] = [];
+  public warnings: { message: string; location?: string; input?: string }[] = [];
+  public shouldTrack = false;
+
+  reset(): void {
+    this.errors = [];
+    this.warnings = [];
+    this.shouldTrack = false;
+  }
+}
+
+export const errorsAndWarnings = new ErrorsAndWarnings();


Test patch:
diff --git a/test/run/FshToFhir.test.ts b/test/run/FshToFhir.test.ts
new file mode 100644
index 0000000..a22f5af
--- /dev/null
+++ b/test/run/FshToFhir.test.ts
@@ -0,0 +1,209 @@
+import fs from 'fs-extra';
+import path from 'path';
+import { loggerSpy } from '../testhelpers';
+import { errorsAndWarnings, logger } from '../../src/utils/';
+import { fshToFhir } from '../../src/run';
+import * as utils from '../../src/utils';
+import { Configuration } from '../../src/fshtypes';
+import { FHIRDefinitions } from '../../src/fhirdefs';
+
+describe('#FshToFhir', () => {
+  let loadSpy: jest.SpyInstance;
+  let defaultConfig: Configuration;
+
+  beforeAll(() => {
+    loadSpy = jest.spyOn(utils, 'loadExternalDependencies').mockImplementation(() => {
+      return [undefined];
+    });
+    defaultConfig = {
+      canonical: 'http://example.org',
+      FSHOnly: true,
+      fhirVersion: ['4.0.1']
+    };
+  });
+
+  beforeEach(() => {
+    loadSpy.mockClear();
+    loggerSpy.reset();
+    errorsAndWarnings.reset();
+  });
+
+  it('should use the "info" logging level by default', async () => {
+    await expect(fshToFhir('')).resolves.toEqual({
+      errors: [],
+      warnings: [],
+      fhir: []
+    });
+    expect(logger.level).toBe('info');
+  });
+
+  it('should use a higher logging level when specified', async () => {
+    const results = await fshToFhir('Bad FSH', { logLevel: 'error' });
+    expect(results.errors).toHaveLength(1);
+    expect(results.errors[0].location).toEqual({
+      startColumn: 1,
+      startLine: 1,
+      endColumn: 3,
+      endLine: 1
+    });
+    expect(results.errors[0].message).toMatch(/mismatched input 'Bad'/);
+    expect(results.warnings).toHaveLength(0);
+    expect(results.fhir).toEqual([]);
+    expect(logger.level).toBe('error');
+  });
+
+  it('should mute the logger when "silent" is specified', async () => {
+    const results = await fshToFhir('Bad FSH', { logLevel: 'silent' });
+    expect(results.errors).toHaveLength(1);
+    expect(results.errors[0].location).toEqual({
+      startColumn: 1,
+      startLine: 1,
+      endColumn: 3,
+      endLine: 1
+    });
+    // errors are still tracked, even when the logger is silent
+    expect(results.errors[0].message).toMatch(/mismatched input 'Bad'/);
+    expect(results.warnings).toHaveLength(0);
+    expect(results.fhir).toEqual([]);
+    expect(logger.transports[0].silent).toBe(true);
+  });
+
+  it('should quit and return an error when an invalid logLevel is specified', async () => {
+    // @ts-ignore
+    const results = await fshToFhir('Bad FSH', { logLevel: '11' });
+    expect(results.errors).toHaveLength(1);
+    expect(results.errors[0].message).toMatch(
+      /Invalid logLevel: 11. Valid levels include: silly, debug, verbose, http, info, warn, error, silent/
+    );
+    expect(results.warnings).toHaveLength(0);
+    expect(results.fhir).toBeNull();
+  });
+
+  it('should replace configuration options when specified', async () => {
+    await expect(
+      fshToFhir('', {
+        canonical: 'http://mycanonical.org',
+        dependencies: [{ packageId: 'hl7.fhir.test.core', version: '1.2.3' }],
+        version: '3.2.1'
+      })
+    ).resolves.toEqual({
+      errors: [],
+      warnings: [],
+      fhir: []
+    });
+  });
+
+  it('should load external dependencies', async () => {
+    fshToFhir('');
+    expect(loadSpy.mock.calls).toHaveLength(1);
+    expect(loadSpy.mock.calls[0]).toEqual([new FHIRDefinitions(), defaultConfig]);
+  });
+
+  describe('#Conversion', () => {
+    beforeAll(() => {
+      const sd = JSON.parse(
+        fs.readFileSync(
+          path.join(
+            __dirname,
+            '..',
+            'testhelpers',
+            'testdefs',
+            'package',
+            'StructureDefinition-StructureDefinition.json'
+          ),
+          'utf-8'
+        )
+      );
+      const patient = JSON.parse(
+        fs.readFileSync(
+          path.join(
+            __dirname,
+            '..',
+            'testhelpers',
+            'testdefs',
+            'package',
+            'StructureDefinition-Patient.json'
+          ),
+          'utf-8'
+        )
+      );
+      loadSpy.mockImplementation(defs => {
+        defs.add(sd);
+        defs.add(patient);
+        return [undefined];
+      });
+    });
+
+    afterAll(() => {
+      loadSpy.mockImplementation(() => {
+        return [undefined];
+      });
+    });
+
+    it('should convert valid FSH into FHIR with a single input', async () => {
+      const results = await fshToFhir(
+        `
+      Profile: MyPatient
+      Parent: Patient
+      * name MS
+      `
+      );
+      expect(results.errors).toHaveLength(0);
+      expect(results.warnings).toHaveLength(0);
+
+      expect(results.fhir).toHaveLength(1);
+      expect(results.fhir[0].id).toBe('MyPatient');
+      const name = results.fhir[0].differential.element.find((e: any) => e.id == 'Patient.name');
+      expect(name.mustSupport).toBe(true);
+    });
+
+    it('should convert valid FSH into FHIR with several inputs', async () => {
+      const results = await fshToFhir([
+        `
+      Profile: MyPatient1
+      Parent: Patient
+      * name MS
+      `,
+        `
+      Profile: MyPatient2
+      Parent: Patient
+      * gender MS
+      `
+      ]);
+      expect(results.errors).toHaveLength(0);
+      expect(results.warnings).toHaveLength(0);
+      expect(results.fhir).toHaveLength(2);
+      expect(results.fhir[0].id).toBe('MyPatient1');
+      const name = results.fhir[0].differential.element.find((e: any) => e.id == 'Patient.name');
+      expect(name.mustSupport).toBe(true);
+      expect(results.fhir[1].id).toBe('MyPatient2');
+      const gender = results.fhir[1].differential.element.find(
+        (e: any) => e.id == 'Patient.gender'
+      );
+      expect(gender.mustSupport).toBe(true);
+    });
+
+    it('should trace errors back to the originating input when multiple inputs are given', async () => {
+      const results = await fshToFhir([
+        `
+      Profile: MyPatient1
+      Parent: FakeProfile
+      * name MS
+      `,
+        `
+      Profile: MyPatient2
+      Parent: AlsoFakeProfile
+      * gender MS
+      `
+      ]);
+      expect(results.errors).toHaveLength(2);
+      expect(results.errors[0].message).toMatch(/Parent FakeProfile not found/);
+      expect(results.errors[0].input).toBe('Input_0');
+      expect(results.errors[1].message).toMatch(/Parent AlsoFakeProfile not found/);
+      expect(results.errors[1].input).toBe('Input_1');
+      expect(results.warnings).toHaveLength(0);
+      expect(results.fhir).toHaveLength(0);
+      expect(results.fhir).toEqual([]);
+    });
+  });
+});
diff --git a/test/utils/FSHLogger.test.ts b/test/utils/FSHLogger.test.ts
index 24106e8..ab9a3a5 100644
--- a/test/utils/FSHLogger.test.ts
+++ b/test/utils/FSHLogger.test.ts
@@ -1,4 +1,4 @@
-import { logger, stats } from '../../src/utils/FSHLogger';
+import { logger, stats, errorsAndWarnings } from '../../src/utils/FSHLogger';
 
 // MUTE_LOGS controls whether or not logs get printed during testing.
 // Usually, we don't want logs actually printed, as they cause clutter.
@@ -9,6 +9,7 @@ describe('FSHLogger', () => {
 
   beforeEach(() => {
     stats.reset();
+    errorsAndWarnings.reset();
     if (MUTE_LOGS) {
       originalWriteFn = logger.transports[0]['write'];
       logger.transports[0]['write'] = jest.fn(() => true);
@@ -73,4 +74,45 @@ describe('FSHLogger', () => {
     expect(stats.numWarn).toBe(0);
     expect(stats.numError).toBe(0);
   });
+
+  it('should not track errors and warnings when shouldTrack is false', () => {
+    logger.warn('warn1');
+    logger.error('error1');
+    expect(errorsAndWarnings.shouldTrack).toBe(false);
+    expect(errorsAndWarnings.errors).toHaveLength(0);
+    expect(errorsAndWarnings.warnings).toHaveLength(0);
+  });
+
+  it('should track errors and warnings when shouldTrack is true', () => {
+    errorsAndWarnings.shouldTrack = true;
+    logger.warn('warn1', { location: [1, 2, 3, 4], file: 'Input_0' });
+    logger.warn('warn2');
+    logger.error('error1');
+    logger.error('error2');
+    expect(errorsAndWarnings.shouldTrack).toBe(true);
+    expect(errorsAndWarnings.warnings).toHaveLength(2);
+    expect(errorsAndWarnings.warnings).toContainEqual({
+      location: [1, 2, 3, 4],
+      message: 'warn1',
+      input: 'Input_0'
+    });
+    expect(errorsAndWarnings.warnings).toContainEqual({ message: 'warn2' });
+    expect(errorsAndWarnings.errors).toHaveLength(2);
+    expect(errorsAndWarnings.errors).toContainEqual({ message: 'error1' });
+    expect(errorsAndWarnings.errors).toContainEqual({ message: 'error2' });
+  });
+
+  it('should reset errors and warnings', () => {
+    errorsAndWarnings.shouldTrack = true;
+    logger.warn('warn1');
+    logger.error('error1');
+    expect(errorsAndWarnings.shouldTrack).toBe(true);
+    expect(errorsAndWarnings.warnings).toHaveLength(1);
+    expect(errorsAndWarnings.warnings).toContainEqual({ message: 'warn1' });
+    expect(errorsAndWarnings.errors).toHaveLength(1);
+    expect(errorsAndWarnings.errors).toContainEqual({ message: 'error1' });
+    errorsAndWarnings.reset();
+    expect(errorsAndWarnings.errors).toHaveLength(0);
+    expect(errorsAndWarnings.warnings).toHaveLength(0);
+  });
 });