Repository: FHIR/sushi (ts)

Issue:
Look for R4B in packages2.fhir.org
R4B ballot edition (4.1.0) is currently only in packages2.fhir.org.  We should look there for it like we do for r5.  Also, since it is intended to be temporary, consider looking in packages.fhir.org and then _falling back_ to packages2 if we don't find it.

Pull request description:
Add Support for FHIR R4B
Adds support for FHIR 4.1.0 (R4B prerelease) and the eventually coming FHIR 4.3.0 (R4B official). Also changes logic to only use packages2 after first trying packages.

To test, specify this in your `sushi-config.yaml`:
```
fhirVersion: 4.1.0
```
and include the following FSH definition that uses things changed in R4B:
```
Profile: SubstanceDefinitionProfile
Parent: SubstanceDefinition
* status 1..1

Profile: SubstanceProfile
Parent: Substance
* code only Reference(SubstanceDefinitionProfile)
```

On `master` branch, it fails at the point of trying to download R4B.  On this branch, the download succeeds and the profiles compile w/ no errors.

Fixes #855 

Patch:
diff --git a/src/fhirdefs/load.ts b/src/fhirdefs/load.ts
index 4a1e647..1b2b988 100644
--- a/src/fhirdefs/load.ts
+++ b/src/fhirdefs/load.ts
@@ -112,13 +112,17 @@ export async function loadDependency(
       throw new CurrentPackageLoadError(fullPackageName);
     }
   } else if (!loadedPackage) {
-    // If the package is not locally cached, and it is not a current or dev version, we attempt to get it
-    // from packages.fhir.org or packages2.fhir.org
-    if (packageName.startsWith('hl7.fhir.r5.')) {
-      // Temporary.  See: https://chat.fhir.org/#narrow/stream/179252-IG-creation/topic/Registry.20for.20FHIR.20Core.20packages.20.3E.204.2E0.2E1
-      packageUrl = `http://packages2.fhir.org/packages/${packageName}/${version}`;
-    } else {
-      packageUrl = `http://packages.fhir.org/${packageName}/${version}`;
+    packageUrl = `http://packages.fhir.org/${packageName}/${version}`;
+
+    // If this is an R4B or R5 package, then we may need to get it from packages2 if it is not in packages
+    if (/^hl7\.fhir\.r(4b|5)\./.test(packageName)) {
+      try {
+        await axios.head(packageUrl);
+      } catch {
+        // It didn't exist in the normal registry.  Fallback to packages2 registry. This should be TEMPORARY.
+        // See: https://chat.fhir.org/#narrow/stream/179252-IG-creation/topic/Registry.20for.20FHIR.20Core.20packages.20.3E.204.2E0.2E1
+        packageUrl = `http://packages2.fhir.org/packages/${packageName}/${version}`;
+      }
     }
   }
 
diff --git a/src/utils/Processing.ts b/src/utils/Processing.ts
index c531730..42fbae2 100644
--- a/src/utils/Processing.ts
+++ b/src/utils/Processing.ts
@@ -167,8 +167,20 @@ export async function loadExternalDependencies(
   // Add FHIR to the dependencies so it is loaded
   const dependencies = (config.dependencies ?? []).slice(); // slice so we don't modify actual config;
   const fhirVersion = config.fhirVersion.find(v => isSupportedFHIRVersion(v));
-  const fhirPackageId = fhirVersion.startsWith('4.0') ? 'hl7.fhir.r4.core' : 'hl7.fhir.r5.core';
-  if (fhirPackageId === 'hl7.fhir.r5.core') {
+  let fhirPackageId: string;
+  let prerelease = false;
+  if (fhirVersion.startsWith('4.0.')) {
+    fhirPackageId = 'hl7.fhir.r4.core';
+  } else if (fhirVersion.startsWith('4.1.')) {
+    fhirPackageId = 'hl7.fhir.r4b.core';
+    prerelease = true;
+  } else if (fhirVersion.startsWith('4.3.')) {
+    fhirPackageId = 'hl7.fhir.r4b.core';
+  } else {
+    fhirPackageId = 'hl7.fhir.r5.core';
+    prerelease = true;
+  }
+  if (prerelease) {
     logger.warn(
       'SUSHI support for pre-release versions of FHIR is experimental. Use at your own risk!'
     );


Test patch:
diff --git a/test/fhirdefs/load.test.ts b/test/fhirdefs/load.test.ts
index 7f227e2..15049f3 100644
--- a/test/fhirdefs/load.test.ts
+++ b/test/fhirdefs/load.test.ts
@@ -67,6 +67,7 @@ describe('#loadFromPath()', () => {
 describe('#loadDependency()', () => {
   let defs: FHIRDefinitions;
   let axiosSpy: jest.SpyInstance;
+  let axiosHeadSpy: jest.SpyInstance;
   let tarSpy: jest.SpyInstance;
   let ensureDirSpy: jest.SpyInstance;
   let writeSpy: jest.SpyInstance;
@@ -139,6 +140,8 @@ describe('#loadDependency()', () => {
         uri === 'http://build.fhir.org/ig/sushi/sushi-test-old/package.tgz' ||
         uri === 'http://build.fhir.org/ig/HL7/US-Core-R4/package.tgz' ||
         uri === 'http://build.fhir.org/hl7.fhir.r5.core.tgz' ||
+        uri === 'http://packages2.fhir.org/packages/hl7.fhir.r4b.core/4.1.0' ||
+        uri === 'http://packages.fhir.org/hl7.fhir.r4b.core/4.3.0' ||
         uri === 'http://packages2.fhir.org/packages/hl7.fhir.r5.core/4.5.0' ||
         uri === 'http://packages.fhir.org/hl7.fhir.r4.core/4.0.1'
       ) {
@@ -151,6 +154,16 @@ describe('#loadDependency()', () => {
         return {};
       }
     });
+    axiosHeadSpy = jest.spyOn(axios, 'head').mockImplementation((uri: string): any => {
+      if (
+        uri === 'http://packages.fhir.org/hl7.fhir.r4b.core/4.1.0' ||
+        uri === 'http://packages.fhir.org/hl7.fhir.r5.core/4.5.0'
+      ) {
+        throw 'Not Found';
+      } else {
+        return {};
+      }
+    });
     tarSpy = jest.spyOn(tar, 'x').mockImplementation(() => {});
     writeSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
     ensureDirSpy = jest.spyOn(fs, 'ensureDirSync').mockImplementation(() => {});
@@ -161,6 +174,7 @@ describe('#loadDependency()', () => {
     loggerSpy.reset();
     defs = new FHIRDefinitions();
     axiosSpy.mockClear();
+    axiosHeadSpy.mockClear();
     tarSpy.mockClear();
     writeSpy.mockClear();
     ensureDirSpy.mockClear();
@@ -202,7 +216,32 @@ describe('#loadDependency()', () => {
     expect(tarSpy.mock.calls[0][0].cwd).toBe(path.join('foo', 'hl7.fhir.r4.core#4.0.1'));
   });
 
-  it('should try to load FHIR R5 (4.5.0) from packages2.fhir.org when it is not cached', async () => {
+  it('should try to load prerelease FHIR R4B (4.1.0) from packages2.fhir.org when it is not cached', async () => {
+    await expect(loadDependency('hl7.fhir.r4b.core', '4.1.0', defs, 'foo')).rejects.toThrow(
+      'The package hl7.fhir.r4b.core#4.1.0 could not be loaded locally or from the FHIR package registry'
+    ); // the package is never actually added to the cache, since tar is mocked
+    expect(axiosSpy.mock.calls).toEqual([
+      [
+        'http://packages2.fhir.org/packages/hl7.fhir.r4b.core/4.1.0',
+        { responseType: 'arraybuffer' }
+      ]
+    ]);
+    expect(ensureDirSpy.mock.calls[0]).toEqual([path.join('foo', 'hl7.fhir.r4b.core#4.1.0')]);
+    expect(tarSpy.mock.calls[0][0].cwd).toBe(path.join('foo', 'hl7.fhir.r4b.core#4.1.0'));
+  });
+
+  it('should try to load FHIR R4B (4.3.0) from packages.fhir.org when it is not cached', async () => {
+    await expect(loadDependency('hl7.fhir.r4b.core', '4.3.0', defs, 'foo')).rejects.toThrow(
+      'The package hl7.fhir.r4b.core#4.3.0 could not be loaded locally or from the FHIR package registry'
+    ); // the package is never actually added to the cache, since tar is mocked
+    expect(axiosSpy.mock.calls).toEqual([
+      ['http://packages.fhir.org/hl7.fhir.r4b.core/4.3.0', { responseType: 'arraybuffer' }]
+    ]);
+    expect(ensureDirSpy.mock.calls[0]).toEqual([path.join('foo', 'hl7.fhir.r4b.core#4.3.0')]);
+    expect(tarSpy.mock.calls[0][0].cwd).toBe(path.join('foo', 'hl7.fhir.r4b.core#4.3.0'));
+  });
+
+  it('should try to load prerelease FHIR R5 (4.5.0) from packages2.fhir.org when it is not cached', async () => {
     await expect(loadDependency('hl7.fhir.r5.core', '4.5.0', defs, 'foo')).rejects.toThrow(
       'The package hl7.fhir.r5.core#4.5.0 could not be loaded locally or from the FHIR package registry'
     ); // the package is never actually added to the cache, since tar is mocked
diff --git a/test/utils/Processing.test.ts b/test/utils/Processing.test.ts
index 193f818..524db2e 100644
--- a/test/utils/Processing.test.ts
+++ b/test/utils/Processing.test.ts
@@ -317,7 +317,7 @@ describe('Processing', () => {
         .mockImplementation(
           async (packageName: string, version: string, FHIRDefs: FHIRDefinitions) => {
             // the mock loader can find hl7.fhir.(r2|r3|r4|r5|us).core
-            if (/^hl7.fhir.(r2|r3|r4|r5|us).core$/.test(packageName)) {
+            if (/^hl7.fhir.(r2|r3|r4|r4b|r5|us).core$/.test(packageName)) {
               FHIRDefs.packages.push(`${packageName}#${version}`);
               return Promise.resolve(FHIRDefs);
             } else {
@@ -342,7 +342,29 @@ describe('Processing', () => {
       });
     });
 
-    it('should support FHIR R5 dependencies', () => {
+    it('should support prerelease FHIR R4B dependencies', () => {
+      const config = cloneDeep(minimalConfig);
+      config.fhirVersion = ['4.1.0'];
+      const defs = new FHIRDefinitions();
+      return loadExternalDependencies(defs, config).then(() => {
+        expect(defs.packages).toEqual(['hl7.fhir.r4b.core#4.1.0']);
+        expect(loggerSpy.getLastMessage('warn')).toMatch(
+          /support for pre-release versions of FHIR is experimental/s
+        );
+      });
+    });
+
+    it('should support official FHIR R4B dependency (will be 4.3.0)', () => {
+      const config = cloneDeep(minimalConfig);
+      config.fhirVersion = ['4.3.0'];
+      const defs = new FHIRDefinitions();
+      return loadExternalDependencies(defs, config).then(() => {
+        expect(defs.packages).toEqual(['hl7.fhir.r4b.core#4.3.0']);
+        expect(loggerSpy.getAllLogs('warn')).toHaveLength(0);
+      });
+    });
+
+    it('should support prerelease FHIR R5 dependencies', () => {
       const config = cloneDeep(minimalConfig);
       config.fhirVersion = ['4.5.0'];
       const defs = new FHIRDefinitions();