Skip to content

Commit

Permalink
Merge pull request #125 from mechanik-daniel/replace-fpl
Browse files Browse the repository at this point in the history
Switch FHIR Package Loader with a native approach
  • Loading branch information
mechanik-daniel authored Dec 6, 2024
2 parents effc771 + 74e4ebb commit b7a14c9
Show file tree
Hide file tree
Showing 20 changed files with 3,095 additions and 3,432 deletions.
3 changes: 0 additions & 3 deletions .env.example.stateful
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,3 @@
# FHIR packages to load (one package@version or a comma seperated list of package@version)
# If you want the latest version of a package to be downloaded at startup, just omit the its @version part
#[email protected],hl7.fhir.us.core

# FHIR packages that if they exist in the FHIR cache - shall be ignored by FUME
#[email protected],[email protected]
3 changes: 0 additions & 3 deletions .env.example.stateless
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,3 @@ SERVER_STATELESS=true
# FHIR packages to load (one package@version or a comma seperated list of package@version)
# If you want the latest version of a package to be downloaded at startup, just omit the its @version part
#[email protected],hl7.fhir.us.core

# FHIR packages that if they exist in the FHIR cache - shall be ignored by FUME
#[email protected],[email protected]
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ snapshots
.eslintcache
**/hapi.postgress.data

fhirPackageIndex.json
1 change: 0 additions & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
npx lint-staged
git add .
npm run test:unit
6,020 changes: 2,789 additions & 3,231 deletions package-lock.json

Large diffs are not rendered by default.

48 changes: 25 additions & 23 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,58 +49,60 @@
"url": "git+https://github.com/Outburn-IL/fume-community.git"
},
"dependencies": {
"axios": "^1.7.4",
"axios": "^1.7.9",
"cors": "^2.8.5",
"csvtojson": "^2.0.10",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"fast-xml-parser": "^4.4.0",
"fhir-package-loader": "^1.0.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.0",
"fs-extra": "^11.2.0",
"hl7-dictionary": "^1.0.1",
"hl7js": "^0.0.6",
"js-sha256": "^0.9.0",
"jsonata": "^2.0.5",
"jsonata": "^2.0.6",
"tar": "^7.4.3",
"temp": "^0.9.4",
"uuid-by-string": "^4.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/preset-env": "^7.24.8",
"@babel/preset-typescript": "^7.24.7",
"@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^19.2.2",
"@commitlint/types": "^19.0.3",
"@babel/preset-env": "^7.26.0",
"@babel/preset-typescript": "^7.26.0",
"@commitlint/cli": "^19.6.0",
"@commitlint/config-conventional": "^19.6.0",
"@commitlint/types": "^19.5.0",
"@semantic-release/git": "^10.0.1",
"@stylistic/eslint-plugin-js": "^1.8.1",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.13",
"@types/temp": "^0.9.4",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"args": "^5.0.3",
"cross-env": "^7.0.3",
"detect-indent": "^7.0.1",
"detect-newline": "^4.0.1",
"docker-compose": "^0.24.8",
"eslint": "^8.57.0",
"eslint": "^8.57.1",
"eslint-config-standard-with-typescript": "^23.0.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-promise": "^6.4.0",
"eslint-plugin-promise": "^6.6.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"husky": "^9.0.11",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-mock-axios": "^4.7.3",
"lint-staged": "^15.2.7",
"nodemon": "^3.1.4",
"jest-mock-axios": "^4.8.0",
"lint-staged": "^15.2.10",
"nodemon": "^3.1.7",
"pre-commit": "^1.2.2",
"rimraf": "^5.0.9",
"rimraf": "^5.0.10",
"semantic-release": "^23.1.1",
"semantic-release-unsquash": "^0.2.0",
"semver": "^7.6.2",
"simple-git": "^3.25.0",
"semver": "^7.6.3",
"simple-git": "^3.27.0",
"supertest": "^6.3.4",
"typescript": "^4.9.5"
},
Expand Down
3 changes: 0 additions & 3 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { test } from '@jest/globals';
import config from './config';

const serverDefaults = {
EXCLUDE_FHIR_PACKAGES: '',
FHIR_PACKAGES: '[email protected],[email protected],[email protected],laniado.test.fhir.r4',
FHIR_SERVER_AUTH_TYPE: 'NONE',
FHIR_SERVER_BASE: 'http://hapi-fhir.outburn.co.il/fhir',
Expand Down Expand Up @@ -38,7 +37,6 @@ describe('setServerConfig', () => {
FHIR_SERVER_BASE: 'http://hapi-fhir.outburn.co.il/fhir-test '
});
expect(config.getServerConfig()).toEqual({
EXCLUDE_FHIR_PACKAGES: '',
FHIR_PACKAGES: '[email protected],[email protected],[email protected],laniado.test.fhir.r4',
FHIR_SERVER_AUTH_TYPE: 'NONE',
FHIR_SERVER_BASE: 'http://hapi-fhir.outburn.co.il/fhir-test',
Expand All @@ -57,7 +55,6 @@ describe('setServerConfig', () => {
SERVER_STATELESS: false
});
expect(config.getServerConfig()).toEqual({
EXCLUDE_FHIR_PACKAGES: '',
FHIR_PACKAGES: '[email protected],[email protected],[email protected],laniado.test.fhir.r4',
FHIR_SERVER_AUTH_TYPE: 'NONE',
FHIR_SERVER_BASE: '',
Expand Down
27 changes: 19 additions & 8 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
/**
* © Copyright Outburn Ltd. 2022-2024 All Rights Reserved
* Project name: FUME-COMMUNITY
*/
/**
* © Copyright Outburn Ltd. 2022-2024 All Rights Reserved
* Project name: FUME-COMMUNITY
*/

import { fhirCorePackages } from './constants';
import { fhirVersionToMinor } from './helpers/fhirFunctions/fhirVersionToMinor';
import defaultConfig from './serverConfig';
import { fhirCorePackages } from './constants';
import { fhirVersionToMinor } from './helpers/fhirFunctions/fhirVersionToMinor';
import defaultConfig from './serverConfig';
import type { IAppBinding, IConfig } from './types';

const additionalBindings: Record<string, IAppBinding> = {}; // additional functions to bind when running transformations
let serverConfig: IConfig = { ...defaultConfig };
let fhirPackages: Record<string, string> = {};

const setFhirPackages = (packages: Record<string, string>) => {
fhirPackages = packages;
};

const getFhirPackages = () => {
return fhirPackages;
};

const setServerConfig = <ConfigType extends IConfig>(config: Partial<ConfigType>) => {
let fhirServerBase: string | undefined = config.FHIR_SERVER_BASE ? config.FHIR_SERVER_BASE.trim() : undefined;
Expand Down Expand Up @@ -60,5 +69,7 @@ export default {
getServerConfig,
setServerConfig,
setBinding,
getBindings
getBindings,
setFhirPackages,
getFhirPackages
};
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,5 @@ export const iso3166 = {
ZMB: 'Zambia',
ZWE: 'Zimbabwe'
};

export const fumeFhirPackageId: string = 'fume.outburn.r4';
185 changes: 185 additions & 0 deletions src/helpers/conformance/ensurePackageInstalled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* © Copyright Outburn Ltd. 2022-2024 All Rights Reserved
* Project name: FUME-COMMUNITY
*/

import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import * as tar from 'tar';
import temp from 'temp';

import { getLogger } from '../logger';
import { getCachePackagesPath } from './getCachePath';

interface PackageObject {
id: string
version: string
};

const cachePath = getCachePackagesPath();
const registryUrl = 'https://packages.fhir.org';
const fallbackTarballUrl = (packageObject: PackageObject) => `https://packages.simplifier.net/${packageObject.id}/-/${packageObject.id}-${packageObject.version}.tgz`;

/**
* Takes a PackageObject and returns the corresponding directory name of the package
* @param packageObject A PackageObject with both name and version keys
* @returns (string) Directory name in the standard format `name#version`
*/
const toDirName = (packageObject: PackageObject): string => packageObject.id + '#' + packageObject.version;

/**
* Checks if the package folder is found in the package cache
* @param packageObject An object with `name` and `version` keys
* @returns `true` if the package folder was found, `false` otherwise
*/
const isInstalled = (packageObject: PackageObject): boolean => {
const dirName = toDirName(packageObject);
const packPath = path.join(cachePath, dirName);
return fs.existsSync(packPath);
};

/**
* Extracts the version of the package
* @param packageId (string) Raw package identifier string. Could be `name@version`, `name#version` or just `name`
* @returns The version part of the package identifier. If not supplied, `latest` will be returned
*/
const getVersionFromPackageString = (packageId: string): string => {
const byPound = packageId.split('#');
const byAt = packageId.split('@');
if (byPound.length === 2) return byPound[1];
if (byAt.length === 2) return byAt[1];
return 'latest';
};

/**
* Queries the registry for the package information
* @param packageName Only the package name (no version)
* @returns The response object from the registry
*/
const getPackageDataFromRegistry = async (packageName: string): Promise<Record<string, any | any[]>> => {
const packageData = await axios.get(`${registryUrl}/${packageName}/`);
return packageData.data;
};

/**
* Checks the package registry for the latest published version of a package
* @param packageName (string) The package id alone, without the version part
* @returns The latest published version of the package
*/
const checkLatestPackageDist = async (packageName: string): Promise<string> => {
const packageData = await getPackageDataFromRegistry(packageName);
const latest = packageData['dist-tags']?.latest;
return latest;
};

/**
* Parses a package identifier string into a PackageObject.
* If the version was not supplied it will be resolved to the latest published version
* @param packageId (string) Raw package identifier string. Could be `name@version`, `name#version` or just `name`
* @returns a PackageObject with name and version
*/
const toPackageObject = async (packageId: string): Promise<PackageObject> => {
const packageName: string = packageId.split('#')[0].split('@')[0];
let packageVersion: string = getVersionFromPackageString(packageId);
if (packageVersion === 'latest') packageVersion = await checkLatestPackageDist(packageName);
return { id: packageName, version: packageVersion };
};

/**
* Resolve a package object into a URL for the package tarball
* @param packageObject
* @returns Tarball URL
*/
const getTarballUrl = async (packageObject: PackageObject): Promise<string> => {
let tarballUrl: string;
try {
const packageData = await getPackageDataFromRegistry(packageObject.id);
const versionData = packageData.versions[packageObject.version];
tarballUrl = versionData?.dist?.tarball;
} catch {
tarballUrl = fallbackTarballUrl(packageObject);
};
return tarballUrl;
};

/**
* Move an extracted package content from temporary directory into the FHIR package cache
* @param packageObject
* @param tempDirectory
* @returns The final path of the package in the cache
*/
const cachePackageTarball = async (packageObject: PackageObject, tempDirectory: string): Promise<string> => {
const finalPath = path.join(cachePath, toDirName(packageObject));
if (!isInstalled(packageObject)) {
await fs.move(tempDirectory, finalPath);
getLogger().info(`Installed ${packageObject.id}@${packageObject.version} in the FHIR package cache: ${finalPath}`);
}
return finalPath;
};

/**
* Downloads the tarball file into a temp folder and returns the path
* @param packageObject
*/
const downloadTarball = async (packageObject: PackageObject): Promise<string> => {
const tarballUrl: string = await getTarballUrl(packageObject);
const res = await axios.get(tarballUrl, { responseType: 'stream' });
if (res?.status === 200 && res?.data) {
try {
const tarballStream: Readable = res.data;
temp.track();
const tempDirectory = temp.mkdirSync();
await pipeline(tarballStream, tar.x({ cwd: tempDirectory }));
getLogger().info(`Downloaded ${packageObject.id}@${packageObject.version} to a temporary directory`);
return tempDirectory;
} catch (e) {
getLogger().error(`Failed to extract tarball of package ${packageObject.id}@${packageObject.version}`);
throw e;
}
} else {
throw new Error(`Failed to download package ${packageObject.id}@${packageObject.version} from URL: ${tarballUrl}`);
}
};

const getDependencies = async (packageObject: PackageObject) => {
const manifestPath: string = path.join(cachePath, toDirName(packageObject), 'package', 'package.json');
const manifestFile = await fs.readFile(manifestPath, { encoding: 'utf8' });
if (manifestFile) {
const manifest = JSON.parse(manifestFile);
return manifest?.dependencies ?? {};
} else {
getLogger().warn(`Could not find package manifest for ${packageObject.id}@${packageObject.version}`);
return {};
}
};

/**
* Ensures that a package and all of its dependencies are installed in the global package cache.
* If a version is not supplied, the latest release will be looked up and installed.
* @param packagId string in the format packageId@version | packageId | packageId#version
*/

const ensure = async (packageId: string) => {
const packageObject = await toPackageObject(packageId);
const installed = isInstalled(packageObject);
if (!installed) {
try {
const tempPath: string = await downloadTarball(packageObject);
await cachePackageTarball(packageObject, tempPath);
} catch (e) {
getLogger().error(e);
throw new Error(`Failed to install package ${packageId}`);
}
};
// package itself is installed now. Ensure dependencies.
const deps: Record<string, string> = await getDependencies(packageObject);
for (const pack in deps) {
await ensure(pack + '@' + deps[pack]);
};
return true;
};

export default ensure;
2 changes: 1 addition & 1 deletion src/helpers/conformance/getCachePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,5 @@ export const getCachedPackageDirs = () => {
};

export const getFumeIndexFilePath = () => {
return path.join(getCachePath(), 'fume.index.json');
return path.join('.', 'fhirPackageIndex.json');
};
5 changes: 2 additions & 3 deletions src/helpers/conformance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
*/
import { getCodeSystem, getStructureDefinition, getTable, getValueSet } from './conformance';
import { getFhirPackageIndex, IFhirPackageIndex, loadFhirPackageIndex } from './loadFhirPackageIndex';
import { loadPackage, loadPackages } from './loadPackages';
import { downloadPackages } from './loadPackages';
import { getAliasResource, recacheFromServer } from './recacheFromServer';

export {
downloadPackages,
getAliasResource,
getCodeSystem,
getFhirPackageIndex,
Expand All @@ -16,7 +17,5 @@ export {
getValueSet,
IFhirPackageIndex,
loadFhirPackageIndex,
loadPackage,
loadPackages,
recacheFromServer
};
Loading

0 comments on commit b7a14c9

Please sign in to comment.