diff --git a/README.md b/README.md index 43cebdf..f8099f9 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,13 @@ Usage: fpl install [options] download and unzip specified FHIR packages Arguments: - fhirPackages list of FHIR packages to load using the format packageId#packageVersion or packageId@packageVersion + fhirPackages list of FHIR packages to load using the format packageId#packageVersion or packageId@packageVersion Options: - -c, --cachePath where to save packages to and load definitions from (default is the local [FHIR cache](https://confluence.hl7.org/pages/viewpage.action?pageId=66928417#FHIRPackageCache-Location)) - -d, --debug output extra debugging information - -h, --help display help for command + -c, --cachePath where to save packages to and load definitions from (default is the local FHIR cache) + -d, --debug output extra debugging information + -e, --export export a SQLite DB file with data from the loaded packages + -h, --help display help for command ``` General information about any command can be found with `fpl --help`: @@ -78,6 +79,7 @@ FHIR Package Loader can be used as a library to download FHIR packages, query th ```ts export interface PackageLoader { loadPackage(name: string, version: string): Promise; + loadVirtualPackage(pkg: VirtualPackage): Promise; getPackageLoadStatus(name: string, version: string): LoadStatus; findPackageInfos(name: string): PackageInfo[]; findPackageInfo(name: string, version: string): PackageInfo | undefined; @@ -101,6 +103,7 @@ The [default PackageLoader](src/loader/DefaultPackageLoader.ts) implementation p * the standard FHIR registry is used (`packages.fhir.org`) for downloading published packages, falling back to `packages2.fhir.org` when necessary * unless an `FPL_REGISTRY` environment variable is defined, in which case its value is used as the URL for an NPM registry to use _instead_ of the standard FHIR registry * the `build.fhir.org` build server is used for downloading _current_ builds of packages +* a 500-item LRU in-memory cache is used to minimize repeated disk reads for resource files To instantiate the default `PackageLoader`, import the asynchronous `defaultPackageLoader` function and invoke it, optionally passing in an `options` object with a log method to use for logging: @@ -116,9 +119,7 @@ if (status !== LoadStatus.LOADED) { } ``` -To instantiate the default `PackageLoader` with a set of standalone JSON or XML resources that should be pre-loaded, use the `defaultPackageLoaderWithLocalResources` function instead, passing in an array of file paths to folders containing the resources to load. - -For more control over the `PackageLoader`, use the [BasePackageLoader](src/loader/BasePackageLoader.ts). This allows you to specify the [PackageDB](src/db), [PackageCache](src/cache), [RegistryClient](src/registry), and [CurrentBuildClient](src/current) you wish to use. FHIRPackageLoader comes with implementations of each of these, but you may also provide your own implementations that adhere to the relevant interfaces. +For more control over the `PackageLoader`, use the [BasePackageLoader](src/loader/BasePackageLoader.ts). This allows you to specify the [PackageDB](src/db), [PackageCache](src/cache), [RegistryClient](src/registry), and [CurrentBuildClient](src/current) you wish to use FHIRPackageLoader comes with implementations of each of these, but you may also provide your own implementations that adhere to the relevant interfaces. The BasePackageLoader also allows you to configure the size of the in-memory LRU resource cache. ### PackageLoader Functions @@ -128,6 +129,10 @@ The `PackageLoader` interface provides the following functions: Loads the specified package version. The version may be a specific version (e.g., `1.2.3`), a wildcard patch version (e.g., `1.2.x`), `dev` (to indicate the local development build in your FHIR cache), `current` (to indicate the current master/main build), `current$branchname` (to indicate the current build on a specific branch), or `latest` (to indicate the most recent published version). Returns the [LoadStatus](src/loader/PackageLoader.ts). +#### `loadVirtualPackage(pkg: VirtualPackage): Promise` + +Loads a resources from a passed in implementation of the [VirtualPackage](src/virtual/VirtualPackage.ts) interface. This allows for "virtual" packages that do not come from a registry nor are stored in the local FHIR package cache. The [DiskBasedVirtualPackage](src/virtual/DiskBasedVirtualPackage.ts) implementation allows resources from arbitrary file paths (folders or direct files) to be loaded as a package. The [InMemoryVirtualPackage](src/virtual/InMemoryVirtualPackage.ts) implementation allows resources in a Map to be loaded as a package. Developers may also provide their own implementation of the VirtualPackage interface. Returns the [LoadStatus](src/loader/PackageLoader.ts). + #### `getPackageLoadStatus(name: string, version: string): LoadStatus` Gets the [LoadStatus](src/loader/PackageLoader.ts) for the specified package version. The returned value will be `LoadStatus.LOADED` if it is already loaded, `LoadStatus.NOT_LOADED` if it has not yet been loaded, or `LoadStatus.FAILED` if it was attempted but failed to load. This function supports specific versions (e.g. `1.2.3`), `dev`, `current`, and `current$branchname`. It does _not_ support wildcard patch versions (e.g., `1.2.x`) nor does it support the `latest` version. diff --git a/src/app.ts b/src/app.ts index dd11b5a..8d00b05 100644 --- a/src/app.ts +++ b/src/app.ts @@ -37,7 +37,7 @@ async function install(fhirPackages: string[], options: OptionValues) { const SQL = await initSqlJs(); const packageDB = new SQLJSPackageDB(new SQL.Database()); const fhirCache = options.cachePath ?? path.join(os.homedir(), '.fhir', 'packages'); - const packageCache = new DiskBasedPackageCache(fhirCache, [], { log }); + const packageCache = new DiskBasedPackageCache(fhirCache, { log }); const registryClient = new DefaultRegistryClient({ log }); const buildClient = new BuildDotFhirDotOrgClient({ log }); const loader = new BasePackageLoader(packageDB, packageCache, registryClient, buildClient, { @@ -48,6 +48,15 @@ async function install(fhirPackages: string[], options: OptionValues) { const [name, version] = pkg.split(/[#@]/, 2); await loader.loadPackage(name, version); } + + if (options.export) { + const fplExport = await loader.exportDB(); + if (fplExport.mimeType === 'application/x-sqlite3') { + const exportPath = path.join(process.cwd(), 'FPL.sqlite'); + fs.writeFileSync(exportPath, fplExport.data); + logger.info(`Exported FPL database to ${exportPath}`); + } + } } async function app() { @@ -70,6 +79,7 @@ async function app() { 'where to save packages to and load definitions from (default is the local FHIR cache)' ) .option('-d, --debug', 'output extra debugging information') + .option('-e, --export', 'export a SQLite DB file with data from the loaded packages') .action(install); await program.parseAsync(process.argv); diff --git a/src/cache/DiskBasedPackageCache.ts b/src/cache/DiskBasedPackageCache.ts index 700b4a3..bac9aa2 100644 --- a/src/cache/DiskBasedPackageCache.ts +++ b/src/cache/DiskBasedPackageCache.ts @@ -1,35 +1,23 @@ import path from 'path'; import fs from 'fs-extra'; import { LogFunction } from '../utils'; -import { - PackageCache, - PackageCacheOptions, - LOCAL_PACKAGE_NAME, - LOCAL_PACKAGE_VERSION -} from './PackageCache'; +import { PackageCache, PackageCacheOptions } from './PackageCache'; import temp from 'temp'; import * as tar from 'tar'; import { Readable } from 'stream'; import { pipeline } from 'stream/promises'; -import { LRUCache } from 'mnemonist'; import { Fhir as FHIRConverter } from 'fhir/fhir'; export class DiskBasedPackageCache implements PackageCache { private log: LogFunction; - private localResourceFolders: string[]; private fhirConverter: FHIRConverter; - private lruCache: LRUCache; constructor( private cachePath: string, - localResourceFolders: string[] = [], options: PackageCacheOptions = {} ) { this.log = options.log ?? (() => {}); - this.localResourceFolders = localResourceFolders.map(f => path.resolve(f)); this.fhirConverter = new FHIRConverter(); - // TODO: Make Cache Size Configurable - this.lruCache = new LRUCache(500); } async cachePackageTarball(name: string, version: string, data: Readable): Promise { @@ -51,32 +39,19 @@ export class DiskBasedPackageCache implements PackageCache { } isPackageInCache(name: string, version: string): boolean { - if (isLocalPackage(name, version)) { - return true; - } return fs.existsSync(path.resolve(this.cachePath, `${name}#${version}`)); } getPackagePath(name: string, version: string): string | undefined { if (this.isPackageInCache(name, version)) { - if (isLocalPackage(name, version)) { - return this.localResourceFolders.join(';'); - } return path.resolve(this.cachePath, `${name}#${version}`); } } getPackageJSONPath(name: string, version: string): string | undefined { - if (!isLocalPackage(name, version)) { - const jsonPath = path.resolve( - this.cachePath, - `${name}#${version}`, - 'package', - 'package.json' - ); - if (fs.existsSync(jsonPath)) { - return jsonPath; - } + const jsonPath = path.resolve(this.cachePath, `${name}#${version}`, 'package', 'package.json'); + if (fs.existsSync(jsonPath)) { + return jsonPath; } } @@ -85,104 +60,37 @@ export class DiskBasedPackageCache implements PackageCache { return []; } - if (isLocalPackage(name, version)) { - const spreadSheetCounts = new Map(); - const invalidFileCounts = new Map(); - const resourcePaths: string[] = []; - this.localResourceFolders.forEach(folder => { - let spreadSheetCount = 0; - let invalidFileCount = 0; - fs.readdirSync(folder, { withFileTypes: true }) - .filter(entry => { - if (!entry.isFile()) { - return false; - } else if (/\.json$/i.test(entry.name)) { - return true; - } else if (/-spreadsheet.xml/i.test(entry.name)) { - spreadSheetCount++; - this.log( - 'debug', - `Skipped spreadsheet XML file: ${path.resolve(entry.path, entry.name)}` - ); - return false; - } else if (/\.xml/i.test(entry.name)) { - const xml = fs.readFileSync(path.resolve(entry.path, entry.name)).toString(); - if (/<\?mso-application progid="Excel\.Sheet"\?>/m.test(xml)) { - spreadSheetCount++; - this.log( - 'debug', - `Skipped spreadsheet XML file: ${path.resolve(entry.path, entry.name)}` - ); - return false; - } - return true; - } - invalidFileCount++; - this.log( - 'debug', - `Skipped non-JSON / non-XML file: ${path.resolve(entry.path, entry.name)}` - ); - return false; - }) - .forEach(entry => resourcePaths.push(path.resolve(entry.path, entry.name))); - spreadSheetCounts.set(folder, spreadSheetCount); - invalidFileCounts.set(folder, invalidFileCount); - }); - spreadSheetCounts.forEach((count, folder) => { - if (count) { - this.log( - 'info', - `Found ${count} spreadsheet(s) in directory: ${folder}. SUSHI does not support spreadsheets, so any resources in the spreadsheets will be ignored. To see the skipped files in the logs, run SUSHI with the "--log-level debug" flag.` - ); - } - }); - invalidFileCounts.forEach((count, folder) => { - if (count) { - this.log( - 'info', - `Found ${count} non-JSON / non-XML file(s) in directory: ${folder}. SUSHI only processes resource files with JSON or XML extensions. To see the skipped files in the logs, run SUSHI with the "--log-level debug" flag.` - ); - } - }); - return resourcePaths; - } else { - const contentPath = path.resolve(this.cachePath, `${name}#${version}`, 'package'); - return fs - .readdirSync(contentPath, { withFileTypes: true }) - .filter(entry => entry.isFile() && /^[^.].*\.json$/i.test(entry.name)) - .map(entry => path.resolve(entry.path, entry.name)); - } + const contentPath = path.resolve(this.cachePath, `${name}#${version}`, 'package'); + // Since every OS may load paths in a different order, ensure consistency by sorting the final paths + return fs + .readdirSync(contentPath, { withFileTypes: true }) + .filter(entry => entry.isFile() && /^[^.].*\.json$/i.test(entry.name)) + .map(entry => path.resolve(entry.path, entry.name)) + .sort(); } getResourceAtPath(resourcePath: string) { - let resource = this.lruCache.get(resourcePath); - if (!resource) { - if (/.xml$/i.test(resourcePath)) { - try { - const xml = fs.readFileSync(resourcePath).toString(); - resource = this.fhirConverter.xmlToObj(xml); - } catch { - throw new Error(`Failed to get XML resource at path ${resourcePath}`); - } - } else if (/.json$/i.test(resourcePath)) { - try { - resource = fs.readJSONSync(resourcePath); - } catch { - throw new Error(`Failed to get JSON resource at path ${resourcePath}`); - } - } else { - throw new Error(`Failed to find XML or JSON file at path ${resourcePath}`); + let resource; + if (/.xml$/i.test(resourcePath)) { + try { + const xml = fs.readFileSync(resourcePath).toString(); + resource = this.fhirConverter.xmlToObj(xml); + } catch { + throw new Error(`Failed to get XML resource at path ${resourcePath}`); + } + } else if (/.json$/i.test(resourcePath)) { + try { + resource = fs.readJSONSync(resourcePath); + } catch { + throw new Error(`Failed to get JSON resource at path ${resourcePath}`); } - this.lruCache.set(resourcePath, resource); + } else { + throw new Error(`Failed to find XML or JSON file at path ${resourcePath}`); } return resource; } } -function isLocalPackage(name: string, version: string) { - return name === LOCAL_PACKAGE_NAME && version === LOCAL_PACKAGE_VERSION; -} - /** * This function takes a package which contains contents at the same level as the "package" folder, and nests * all that content within the "package" folder. diff --git a/src/cache/PackageCache.ts b/src/cache/PackageCache.ts index 1a4684a..1fe7555 100644 --- a/src/cache/PackageCache.ts +++ b/src/cache/PackageCache.ts @@ -1,9 +1,6 @@ import { Readable } from 'stream'; import { LogFunction } from '../utils'; -export const LOCAL_PACKAGE_NAME = 'LOCAL'; -export const LOCAL_PACKAGE_VERSION = 'LOCAL'; - export type PackageCacheOptions = { log?: LogFunction; }; diff --git a/src/current/BuildDotFhirDotOrgClient.ts b/src/current/BuildDotFhirDotOrgClient.ts index 5ade78a..d4023b2 100644 --- a/src/current/BuildDotFhirDotOrgClient.ts +++ b/src/current/BuildDotFhirDotOrgClient.ts @@ -9,7 +9,7 @@ export class BuildDotFhirDotOrgClient implements CurrentBuildClient { this.log = options.log ?? (() => {}); } - async downloadCurrentBuild(name: string, branch: string | null): Promise { + async downloadCurrentBuild(name: string, branch?: string): Promise { const version = branch ? `current$${branch}` : 'current'; const baseURL = await this.getCurrentBuildBaseURL(name, branch); if (!baseURL) { diff --git a/src/current/CurrentBuildClient.ts b/src/current/CurrentBuildClient.ts index edfaa94..d54af77 100644 --- a/src/current/CurrentBuildClient.ts +++ b/src/current/CurrentBuildClient.ts @@ -6,6 +6,6 @@ export type CurrentBuildClientOptions = { }; export interface CurrentBuildClient { - downloadCurrentBuild(name: string, branch: string | null): Promise; + downloadCurrentBuild(name: string, branch?: string): Promise; getCurrentBuildDate(name: string, branch?: string): Promise; } diff --git a/src/db/PackageDB.ts b/src/db/PackageDB.ts index dd72229..ad24052 100644 --- a/src/db/PackageDB.ts +++ b/src/db/PackageDB.ts @@ -9,4 +9,5 @@ export interface PackageDB { findResourceInfos(key: string, options?: FindResourceInfoOptions): ResourceInfo[]; findResourceInfo(key: string, options?: FindResourceInfoOptions): ResourceInfo | undefined; getPackageStats(name: string, version: string): PackageStats | undefined; + exportDB(): Promise<{ mimeType: string; data: Buffer }>; } diff --git a/src/db/SQLJSPackageDB.ts b/src/db/SQLJSPackageDB.ts index 58c64c3..8444841 100644 --- a/src/db/SQLJSPackageDB.ts +++ b/src/db/SQLJSPackageDB.ts @@ -100,6 +100,8 @@ const INSERT_RESOURCE = `INSERT INTO resource :resourcePath );`; +const SD_FLAVORS = ['Extension', 'Logical', 'Profile', 'Resource', 'Type']; + export class SQLJSPackageDB implements PackageDB { private insertPackageStmt: Statement; private insertResourceStmt: Statement; @@ -233,16 +235,20 @@ export class SQLJSPackageDB implements PackageDB { key = ''; } // TODO: Upgrade to class-level property once query and parameters are sorted out - // TODO: Support versions when canonical form is used + const [keyText, ...keyVersion] = key.split('|'); const bindStmt: { [key: string]: string } = {}; let findStmt = 'SELECT * FROM resource WHERE '; - if (key !== '*') { + if (keyText !== '*') { // special case for selecting all - bindStmt[':key'] = key; + bindStmt[':key'] = keyText; findStmt += '(id = :key OR name = :key OR url = :key)'; } else { findStmt += '1'; } + if (keyVersion.length) { + bindStmt[':version'] = keyVersion.join('|'); + findStmt += ' AND version = :version'; + } if (options.scope?.length) { const [packageName, ...packageVersion] = options.scope.split('|'); bindStmt[':packageName'] = packageName; @@ -255,11 +261,29 @@ export class SQLJSPackageDB implements PackageDB { if (options.type?.length) { const conditions = options.type.map((t, i) => { bindStmt[`:type${i}`] = t; - return `(sdFlavor = :type${i} OR resourceType = :type${i})`; + const field = SD_FLAVORS.includes(t) ? 'sdFlavor' : 'resourceType'; + return `${field} = :type${i}`; }); - findStmt += ` AND (${conditions.join(' OR ')}) ORDER BY ${conditions.map(c => `${c} DESC`).join(', ')}, rowid DESC`; - } else { - findStmt += ' ORDER BY rowid DESC'; + findStmt += ` AND (${conditions.join(' OR ')})`; + } + if (options.sort) { + const sortExpressions: string[] = []; + options.sort.forEach(s => { + switch (s.sortBy) { + case 'LoadOrder': + sortExpressions.push(`rowId ${s.ascending ? 'ASC' : 'DESC'}`); + break; + case 'Type': + (s.types as string[]).forEach((t, i) => { + bindStmt[`:sortType${i}`] = t; + const field = SD_FLAVORS.includes(t) ? 'sdFlavor' : 'resourceType'; + // This sort expression is weird, but... it's the only way it works as expected! + sortExpressions.push(`(${field} = :sortType${i} OR NULL) DESC`); + }); + break; + } + }); + findStmt += ` ORDER BY ${sortExpressions.join(', ')}`; } if (options.limit) { bindStmt[':limit'] = String(options.limit); @@ -311,6 +335,11 @@ export class SQLJSPackageDB implements PackageDB { }; } + exportDB(): Promise<{ mimeType: string; data: Buffer }> { + const data = this.db.export(); + return Promise.resolve({ mimeType: 'application/x-sqlite3', data: Buffer.from(data) }); + } + logPackageTable() { const res = this.db.exec('SELECT * FROM package'); console.log(util.inspect(res, false, 3, true)); diff --git a/src/errors/LatestVersionUnavailableError.ts b/src/errors/LatestVersionUnavailableError.ts index 831b392..0d89126 100644 --- a/src/errors/LatestVersionUnavailableError.ts +++ b/src/errors/LatestVersionUnavailableError.ts @@ -1,15 +1,12 @@ export class LatestVersionUnavailableError extends Error { constructor( public packageName: string, - public customRegistry?: string, public isPatchWildCard?: boolean ) { super( `Latest ${ isPatchWildCard ? 'patch ' : '' - }version of package ${packageName} could not be determined from the ${ - customRegistry ? 'custom ' : '' - }FHIR package registry${customRegistry ? ` ${customRegistry}` : ''}` + }version of package ${packageName} could not be determined from the package registry` ); } } diff --git a/src/index.ts b/src/index.ts index 6a2f5ba..224385e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,6 @@ export * from './current'; export * from './db'; export * from './loader'; export * from './package'; +export * from './sort'; export * from './registry'; +export * from './virtual'; diff --git a/src/loader/BasePackageLoader.ts b/src/loader/BasePackageLoader.ts index d140aa6..375b03c 100644 --- a/src/loader/BasePackageLoader.ts +++ b/src/loader/BasePackageLoader.ts @@ -1,19 +1,27 @@ import path from 'path'; +import { LRUCache } from 'mnemonist'; import { CurrentBuildClient } from '../current'; import { PackageDB } from '../db'; import { InvalidResourceError } from '../errors'; import { FindResourceInfoOptions, PackageInfo, PackageStats, ResourceInfo } from '../package'; +import { VirtualPackage } from '../virtual'; import { RegistryClient } from '../registry'; import { LogFunction } from '../utils'; import { PackageCache } from '../cache/PackageCache'; import { LoadStatus, PackageLoader } from './PackageLoader'; +const DEFAULT_RESOURCE_CACHE_SIZE = 500; + export type BasePackageLoaderOptions = { log?: LogFunction; + resourceCacheSize?: number; }; export class BasePackageLoader implements PackageLoader { private log: LogFunction; + private virtualPackages: Map; + private resourceCache?: LRUCache; + constructor( private packageDB: PackageDB, private packageCache: PackageCache, @@ -22,11 +30,23 @@ export class BasePackageLoader implements PackageLoader { options: BasePackageLoaderOptions = {} ) { this.log = options.log ?? (() => {}); + this.virtualPackages = new Map(); + const resourceCacheSize = options.resourceCacheSize ?? DEFAULT_RESOURCE_CACHE_SIZE; + if (resourceCacheSize > 0) { + this.resourceCache = new LRUCache(resourceCacheSize); + } } async loadPackage(name: string, version: string): Promise { let packageLabel = `${name}#${version}`; + const originalVersion = version; + version = await this.registryClient.resolveVersion(name, version); + if (version !== originalVersion) { + this.log('info', `Resolved ${packageLabel} to concrete version ${version}`); + packageLabel = `${name}#${version}`; + } + // If it's already loaded, then there's nothing to do if (this.getPackageLoadStatus(name, version) === LoadStatus.LOADED) { this.log('info', `${packageLabel} already loaded`); @@ -43,6 +63,7 @@ export class BasePackageLoader implements PackageLoader { packageLabel = `${name}#${version}`; } + let downloadErrorMessage: string; // If it's a "current" version, download the latest version from the build server (if applicable) if (isCurrentVersion(version)) { const branch = version.indexOf('$') !== -1 ? version.split('$')[1] : undefined; @@ -51,7 +72,7 @@ export class BasePackageLoader implements PackageLoader { const tarballStream = await this.currentBuildClient.downloadCurrentBuild(name, branch); await this.packageCache.cachePackageTarball(name, version, tarballStream); } catch { - this.log('error', `Failed to download ${packageLabel} from current builds`); + downloadErrorMessage = `Failed to download most recent ${packageLabel} from current builds`; } } } @@ -61,7 +82,7 @@ export class BasePackageLoader implements PackageLoader { const tarballStream = await this.registryClient.download(name, version); await this.packageCache.cachePackageTarball(name, version, tarballStream); } catch { - this.log('error', `Failed to download ${packageLabel} from registry`); + downloadErrorMessage = `Failed to download ${packageLabel} from the registry`; } } @@ -70,26 +91,86 @@ export class BasePackageLoader implements PackageLoader { try { stats = this.loadPackageFromCache(name, version); } catch { - this.log('error', `Failed to load ${name}#${version}`); + this.log( + 'error', + `Failed to load ${packageLabel}${downloadErrorMessage ? `: ${downloadErrorMessage}` : ''}` + ); return LoadStatus.FAILED; } + if (downloadErrorMessage) { + // Loading succeeded despite a download error. This might happen if a current build is stale, + // but the download fails, in which case the stale build will be loaded instead. + this.log('error', `${downloadErrorMessage}. Using most recent cached package instead.`); + } this.log('info', `Loaded ${stats.name}#${stats.version} with ${stats.resourceCount} resources`); return LoadStatus.LOADED; } - // async loadLocalPackage( - // name: string, - // version: string, - // packagePath: string, - // strict: boolean - // ): Promise { - // return PackageLoadStatus.FAILED; - // } + async loadVirtualPackage(pkg: VirtualPackage): Promise { + // Ensure package.json has at least the name and version + const packageJSON = pkg.getPackageJSON(); + if ( + packageJSON?.name == null || + packageJSON.name.trim() === '' || + packageJSON.version == null || + packageJSON.version.trim() == '' + ) { + this.log( + 'error', + `Failed to load virtual package ${packageJSON?.name ?? ''}#${packageJSON?.version ?? ''} because the provided packageJSON did not have a valid name and/or version` + ); + return LoadStatus.FAILED; + } + + // If it's already loaded, then there's nothing to do + if (this.getPackageLoadStatus(packageJSON.name, packageJSON.version) === LoadStatus.LOADED) { + this.log('info', `Virtual package ${packageJSON.name}#${packageJSON.version} already loaded`); + return LoadStatus.LOADED; + } + + // Store the virtual package by its name#version key + const packageKey = `${packageJSON.name}#${packageJSON.version}`; + this.virtualPackages.set(packageKey, pkg); + + // Save the package info + const info: PackageInfo = { + name: packageJSON.name, + version: packageJSON.version, + packagePath: `virtual:${packageKey}`, + packageJSONPath: `virtual:${packageKey}:package.json` + }; + this.packageDB.savePackageInfo(info); + + // Register the resources + try { + await pkg.registerResources((key: string, resource: any, allowNonResources?: boolean) => { + this.loadResource( + `virtual:${packageKey}:${key}`, + resource, + packageJSON.name, + packageJSON.version, + allowNonResources + ); + }); + } catch (e) { + this.log( + 'error', + `Virtual package ${packageKey} threw an exception while registering resources, so it was only partially loaded.` + ); + if (e.stack) { + this.log('debug', e.stack); + } + return LoadStatus.FAILED; + } + + const stats = this.packageDB.getPackageStats(packageJSON.name, packageJSON.version); + this.log('info', `Loaded virtual package ${packageKey} with ${stats.resourceCount} resources`); + return LoadStatus.LOADED; + } private loadPackageFromCache(name: string, version: string) { // Ensure the package is cached if (!this.packageCache.isPackageInCache(name, version)) { - // TODO: More specific error? throw new Error(`${name}#${version} cannot be loaded from the package cache`); } @@ -102,13 +183,9 @@ export class BasePackageLoader implements PackageLoader { version }; - if (name === 'LOCAL' && version === 'LOCAL') { - info.packagePath = packagePath; - } else { - const packageJSONPath = this.packageCache.getPackageJSONPath(name, version); - info.packagePath = path.resolve(packagePath); - info.packageJSONPath = path.resolve(packageJSONPath); - } + const packageJSONPath = this.packageCache.getPackageJSONPath(name, version); + info.packagePath = path.resolve(packagePath); + info.packageJSONPath = path.resolve(packageJSONPath); this.packageDB.savePackageInfo(info); @@ -126,7 +203,10 @@ export class BasePackageLoader implements PackageLoader { this.loadResourceFromCache(resourcePath, packageName, packageVersion); } catch { // swallow this error because some JSON files will not be resources - this.log('debug', `JSON file at path ${resourcePath} was not FHIR resource`); + // and don't log it if it is package.json (since every package should have one) + if (path.basename(resourcePath) !== 'package.json') { + this.log('debug', `JSON file at path ${resourcePath} was not FHIR resource`); + } } }); } @@ -140,14 +220,21 @@ export class BasePackageLoader implements PackageLoader { resourcePath: string, resourceJSON: any, packageName?: string, - packageVersion?: string + packageVersion?: string, + allowNonResources = false ) { // We require at least a resourceType in order to know it is FHIR - if (typeof resourceJSON.resourceType !== 'string' || resourceJSON.resourceType === '') { - throw new InvalidResourceError(resourcePath, 'resource does not specify its resourceType'); + let resourceType = resourceJSON.resourceType; + if (typeof resourceType !== 'string' || resourceType === '') { + if (allowNonResources) { + // SUSHI needs to support registering instances of logical models, but some code expects resourceType + resourceType = 'Unknown'; + } else { + throw new InvalidResourceError(resourcePath, 'resource does not specify its resourceType'); + } } - const info: ResourceInfo = { resourceType: resourceJSON.resourceType }; + const info: ResourceInfo = { resourceType }; if (typeof resourceJSON.id === 'string') { info.id = resourceJSON.id; } @@ -160,7 +247,7 @@ export class BasePackageLoader implements PackageLoader { if (typeof resourceJSON.version === 'string') { info.version = resourceJSON.version; } - if (resourceJSON.resourceType === 'StructureDefinition') { + if (resourceType === 'StructureDefinition') { if (typeof resourceJSON.kind === 'string') { info.sdKind = resourceJSON.kind; } @@ -258,6 +345,26 @@ export class BasePackageLoader implements PackageLoader { return isStale; } + private getResourceAtPath(resourcePath: string): any { + let resource = this.resourceCache?.get(resourcePath); + if (!resource) { + if (/^virtual:/.test(resourcePath)) { + const [, packageKey, resourceKey] = resourcePath.match(/^virtual:([^:]+):(.*)$/); + if (packageKey && resourceKey) { + const pkg = this.virtualPackages.get(packageKey); + resource = + resourceKey === 'package.json' + ? pkg?.getPackageJSON() + : pkg?.getResourceByKey(resourceKey); + } + } else { + resource = this.packageCache.getResourceAtPath(resourcePath); + } + this.resourceCache?.set(resourcePath, resource); + } + return resource; + } + getPackageLoadStatus(name: string, version: string): LoadStatus { const pkg = this.packageDB.findPackageInfo(name, version); if (pkg) { @@ -278,14 +385,14 @@ export class BasePackageLoader implements PackageLoader { return this.findPackageInfos(name) .filter(info => info.packageJSONPath) .map(info => { - return this.packageCache.getResourceAtPath(info.packageJSONPath); + return this.getResourceAtPath(info.packageJSONPath); }); } findPackageJSON(name: string, version: string) { const info = this.findPackageInfo(name, version); if (info?.packageJSONPath) { - return this.packageCache.getResourceAtPath(info.packageJSONPath); + return this.getResourceAtPath(info.packageJSONPath); } } @@ -301,17 +408,21 @@ export class BasePackageLoader implements PackageLoader { return this.findResourceInfos(key, options) .filter(info => info.resourcePath) .map(info => { - return this.packageCache.getResourceAtPath(info.resourcePath); + return this.getResourceAtPath(info.resourcePath); }); } findResourceJSON(key: string, options?: FindResourceInfoOptions) { const info = this.findResourceInfo(key, options); if (info?.resourcePath) { - return this.packageCache.getResourceAtPath(info.resourcePath); + return this.getResourceAtPath(info.resourcePath); } } + exportDB(): Promise<{ mimeType: string; data: Buffer }> { + return this.packageDB.exportDB(); + } + clear() { this.packageDB.clear(); } diff --git a/src/loader/DefaultPackageLoader.ts b/src/loader/DefaultPackageLoader.ts index fe0dbbe..2e8462c 100644 --- a/src/loader/DefaultPackageLoader.ts +++ b/src/loader/DefaultPackageLoader.ts @@ -7,20 +7,11 @@ import { DefaultRegistryClient } from '../registry'; import { DiskBasedPackageCache } from '../cache/DiskBasedPackageCache'; import { BasePackageLoader, BasePackageLoaderOptions } from './BasePackageLoader'; -// TODO: New options w/ option for overriding FHIR cache - export async function defaultPackageLoader(options: BasePackageLoaderOptions) { - return defaultPackageLoaderWithLocalResources([], options); -} - -export async function defaultPackageLoaderWithLocalResources( - localResourceFolders: string[], - options: BasePackageLoaderOptions -) { const SQL = await initSqlJs(); const packageDB = new SQLJSPackageDB(new SQL.Database()); const fhirCache = path.join(os.homedir(), '.fhir', 'packages'); - const packageCache = new DiskBasedPackageCache(fhirCache, localResourceFolders, { + const packageCache = new DiskBasedPackageCache(fhirCache, { log: options.log }); const registryClient = new DefaultRegistryClient({ log: options.log }); diff --git a/src/loader/PackageLoader.ts b/src/loader/PackageLoader.ts index f7ed2cb..4430d79 100644 --- a/src/loader/PackageLoader.ts +++ b/src/loader/PackageLoader.ts @@ -1,4 +1,4 @@ -import { FindResourceInfoOptions, PackageInfo, ResourceInfo } from '../package'; +import { FindResourceInfoOptions, PackageInfo, ResourceInfo, VirtualPackage } from '../package'; export enum LoadStatus { LOADED = 'LOADED', @@ -8,6 +8,7 @@ export enum LoadStatus { export interface PackageLoader { loadPackage(name: string, version: string): Promise; + loadVirtualPackage(pkg: VirtualPackage): Promise; getPackageLoadStatus(name: string, version: string): LoadStatus; findPackageInfos(name: string): PackageInfo[]; findPackageInfo(name: string, version: string): PackageInfo | undefined; diff --git a/src/package/PackageJSON.ts b/src/package/PackageJSON.ts new file mode 100644 index 0000000..195e9ba --- /dev/null +++ b/src/package/PackageJSON.ts @@ -0,0 +1,7 @@ +// A minimal package JSON signature +export type PackageJSON = { + name: string; + version: string; + dependencies?: { [key: string]: string }; + [key: string]: any; +}; diff --git a/src/package/ResourceInfo.ts b/src/package/ResourceInfo.ts index 74c93e9..230460a 100644 --- a/src/package/ResourceInfo.ts +++ b/src/package/ResourceInfo.ts @@ -8,10 +8,17 @@ export type FindResourceInfoOptions = { type?: string[]; // search only within a specific package, identified by a package id with an optional "|version" suffix scope?: string; + // sort algorithm(s) + sort?: SortBy[]; // limit the number of results returned limit?: number; }; +export type SortBy = { + sortBy: string; + [key: string]: any; +}; + export type ResourceInfo = { resourceType: string; id?: string; diff --git a/src/package/index.ts b/src/package/index.ts index be6eef1..ecd3dbb 100644 --- a/src/package/index.ts +++ b/src/package/index.ts @@ -1,3 +1,5 @@ export * from './PackageInfo'; +export * from './PackageJSON'; export * from './PackageStats'; export * from './ResourceInfo'; +export * from '../virtual/VirtualPackage'; diff --git a/src/registry/FHIRRegistryClient.ts b/src/registry/FHIRRegistryClient.ts index 57c83b1..3521191 100644 --- a/src/registry/FHIRRegistryClient.ts +++ b/src/registry/FHIRRegistryClient.ts @@ -1,8 +1,7 @@ import { Readable } from 'stream'; import { LogFunction, axiosGet } from '../utils'; import { RegistryClient, RegistryClientOptions } from './RegistryClient'; -import { IncorrectWildcardVersionFormatError } from '../errors'; -import { lookUpLatestVersion, lookUpLatestPatchVersion } from './utils'; +import { resolveVersion } from './utils'; export class FHIRRegistryClient implements RegistryClient { public endpoint: string; @@ -14,15 +13,13 @@ export class FHIRRegistryClient implements RegistryClient { this.log = options.log ?? (() => {}); } + async resolveVersion(name: string, version: string): Promise { + return resolveVersion(this.endpoint, name, version); + } + async download(name: string, version: string): Promise { // Resolve version if necessary - if (version === 'latest') { - version = await lookUpLatestVersion(this.endpoint, name); - } else if (/^\d+\.\d+\.x$/.test(version)) { - version = await lookUpLatestPatchVersion(this.endpoint, name, version); - } else if (/^\d+\.x$/.test(version)) { - throw new IncorrectWildcardVersionFormatError(name, version); - } + version = await this.resolveVersion(name, version); // Construct URL from endpoint, name, and version // See: https://confluence.hl7.org/pages/viewpage.action?pageId=97454344#FHIRPackageRegistryUserDocumentation-Download diff --git a/src/registry/NPMRegistryClient.ts b/src/registry/NPMRegistryClient.ts index 0787c11..4df0761 100644 --- a/src/registry/NPMRegistryClient.ts +++ b/src/registry/NPMRegistryClient.ts @@ -1,8 +1,7 @@ import { Readable } from 'stream'; import { LogFunction, axiosGet } from '../utils'; import { RegistryClient, RegistryClientOptions } from './RegistryClient'; -import { IncorrectWildcardVersionFormatError } from '../errors'; -import { lookUpLatestVersion, lookUpLatestPatchVersion } from './utils'; +import { resolveVersion } from './utils'; export class NPMRegistryClient implements RegistryClient { public endpoint: string; @@ -14,15 +13,13 @@ export class NPMRegistryClient implements RegistryClient { this.log = options.log ?? (() => {}); } + async resolveVersion(name: string, version: string): Promise { + return resolveVersion(this.endpoint, name, version); + } + async download(name: string, version: string): Promise { // Resolve version if necessary - if (version === 'latest') { - version = await lookUpLatestVersion(this.endpoint, name); - } else if (/^\d+\.\d+\.x$/.test(version)) { - version = await lookUpLatestPatchVersion(this.endpoint, name, version); - } else if (/^\d+\.x$/.test(version)) { - throw new IncorrectWildcardVersionFormatError(name, version); - } + version = await this.resolveVersion(name, version); // Get the manifest information about the package from the registry let url; diff --git a/src/registry/RedundantRegistryClient.ts b/src/registry/RedundantRegistryClient.ts index 7def1ae..613b03d 100644 --- a/src/registry/RedundantRegistryClient.ts +++ b/src/registry/RedundantRegistryClient.ts @@ -11,6 +11,19 @@ export class RedundantRegistryClient implements RegistryClient { this.log = options.log ?? (() => {}); } + async resolveVersion(name: string, version: string): Promise { + const packageLabel = `${name}#${version}`; + + for (const client of this.clients) { + try { + return await client.resolveVersion(name, version); + } catch { + // Do nothing. Fallback to the next one. + } + } + throw Error(`Failed to resolve version for ${packageLabel}`); + } + async download(name: string, version: string): Promise { const packageLabel = `${name}#${version}`; diff --git a/src/registry/RegistryClient.ts b/src/registry/RegistryClient.ts index 7d69be3..7246ec4 100644 --- a/src/registry/RegistryClient.ts +++ b/src/registry/RegistryClient.ts @@ -6,5 +6,6 @@ export type RegistryClientOptions = { }; export interface RegistryClient { + resolveVersion(name: string, version: string): Promise; download(name: string, version: string): Promise; } diff --git a/src/registry/utils.ts b/src/registry/utils.ts index 4aeedaa..c709b0e 100644 --- a/src/registry/utils.ts +++ b/src/registry/utils.ts @@ -2,7 +2,23 @@ import { IncorrectWildcardVersionFormatError, LatestVersionUnavailableError } fr import { axiosGet } from '../utils'; import { maxSatisfying } from 'semver'; -export async function lookUpLatestVersion(endpoint: string, name: string): Promise { +export async function resolveVersion( + endpoint: string, + name: string, + version: string +): Promise { + let resolvedVersion = version; + if (version === 'latest') { + resolvedVersion = await lookUpLatestVersion(endpoint, name); + } else if (/^\d+\.\d+\.x$/.test(version)) { + resolvedVersion = await lookUpLatestPatchVersion(endpoint, name, version); + } else if (/^\d+\.x$/.test(version)) { + throw new IncorrectWildcardVersionFormatError(name, version); + } + return resolvedVersion; +} + +async function lookUpLatestVersion(endpoint: string, name: string): Promise { try { const res = await axiosGet(`${endpoint}/${name}`, { responseType: 'json' @@ -17,7 +33,7 @@ export async function lookUpLatestVersion(endpoint: string, name: string): Promi } } -export async function lookUpLatestPatchVersion( +async function lookUpLatestPatchVersion( endpoint: string, name: string, version: string @@ -33,13 +49,13 @@ export async function lookUpLatestPatchVersion( const versions = Object.keys(res.data.versions); const latest = maxSatisfying(versions, version); if (latest == null) { - throw new LatestVersionUnavailableError(name, null, true); + throw new LatestVersionUnavailableError(name, true); } return latest; } else { - throw new LatestVersionUnavailableError(name, null, true); + throw new LatestVersionUnavailableError(name, true); } } catch { - throw new LatestVersionUnavailableError(name, null, true); + throw new LatestVersionUnavailableError(name, true); } } diff --git a/src/sort/byLoadOrder.ts b/src/sort/byLoadOrder.ts new file mode 100644 index 0000000..b29db31 --- /dev/null +++ b/src/sort/byLoadOrder.ts @@ -0,0 +1,8 @@ +import { SortBy } from '../package'; + +export function byLoadOrder(ascending = true): SortBy { + return { + sortBy: 'LoadOrder', + ascending + }; +} diff --git a/src/sort/byType.ts b/src/sort/byType.ts new file mode 100644 index 0000000..c44dab6 --- /dev/null +++ b/src/sort/byType.ts @@ -0,0 +1,8 @@ +import { SortBy } from '../package'; + +export function byType(...type: string[]): SortBy { + return { + sortBy: 'Type', + types: type + }; +} diff --git a/src/sort/index.ts b/src/sort/index.ts new file mode 100644 index 0000000..9e728ea --- /dev/null +++ b/src/sort/index.ts @@ -0,0 +1,2 @@ +export * from './byLoadOrder'; +export * from './byType'; diff --git a/src/virtual/DiskBasedVirtualPackage.ts b/src/virtual/DiskBasedVirtualPackage.ts new file mode 100644 index 0000000..03e3d8f --- /dev/null +++ b/src/virtual/DiskBasedVirtualPackage.ts @@ -0,0 +1,151 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { Fhir as FHIRConverter } from 'fhir/fhir'; +import { PackageJSON } from '../package/PackageJSON'; +import { LogFunction } from '../utils/logger'; +import { VirtualPackage, VirtualPackageOptions } from './VirtualPackage'; + +export type DiskBasedVirtualPackageOptions = VirtualPackageOptions & { + recursive?: boolean; +}; + +export class DiskBasedVirtualPackage implements VirtualPackage { + private log: LogFunction; + private allowNonResources: boolean; + private recursive: boolean; + private fhirConverter: FHIRConverter; + private registeredKeys: Set; + + constructor( + private packageJSON: PackageJSON, + private paths: string[] = [], + options: DiskBasedVirtualPackageOptions = {} + ) { + this.log = options.log ?? (() => {}); + this.allowNonResources = options.allowNonResources ?? false; + this.recursive = options.recursive ?? false; + this.fhirConverter = new FHIRConverter(); + this.registeredKeys = new Set(); + } + + async registerResources( + register: (key: string, resource: any, allowNonResources?: boolean) => void + ): Promise { + const spreadSheetCounts = new Map(); + const invalidFileCounts = new Map(); + + // Since every OS may load paths in a different order, ensure consistency by sorting the paths + const filePaths = getFilePaths(this.paths, this.recursive).sort(); + for (const filePath of filePaths) { + try { + const name = path.basename(filePath); + const parent = path.dirname(filePath); + // Is it a potential resource? + if (/\.json$/i.test(name)) { + register(filePath, this.getResourceAtPath(filePath), this.allowNonResources); + this.registeredKeys.add(filePath); + } else if (/-spreadsheet\.xml$/i.test(name)) { + spreadSheetCounts.set(parent, (spreadSheetCounts.get(parent) ?? 0) + 1); + this.log('debug', `Skipped spreadsheet XML file: ${filePath}`); + } else if (/\.xml$/i.test(name)) { + const xml = fs.readFileSync(filePath).toString(); + if (/<\?mso-application progid="Excel\.Sheet"\?>/m.test(xml)) { + spreadSheetCounts.set(parent, (spreadSheetCounts.get(parent) ?? 0) + 1); + this.log('debug', `Skipped spreadsheet XML file: ${filePath}`); + } + register(filePath, this.getResourceAtPath(filePath), this.allowNonResources); + this.registeredKeys.add(filePath); + } else { + invalidFileCounts.set(parent, (invalidFileCounts.get(parent) ?? 0) + 1); + this.log('debug', `Skipped non-JSON / non-XML file: ${filePath}`); + } + } catch (e) { + if (/convert XML .* Unknown resource type/.test(e.message)) { + // Skip unknown FHIR resource types. When we have instances of Logical Models, + // the resourceType will not be recognized as a known FHIR resourceType, but that's okay. + } else { + this.log('error', `Failed to register resource at path: ${filePath}`); + if (e.stack) { + this.log('debug', e.stack); + } + } + } + } + + spreadSheetCounts.forEach((count, folder) => { + if (count) { + this.log( + 'info', + `Found ${count} spreadsheet(s) in directory: ${folder}. Spreadsheets are not supported, so any resources in the spreadsheets will be ignored. To see the skipped files in the logs, use debug logging.` + ); + } + }); + invalidFileCounts.forEach((count, folder) => { + if (count) { + this.log( + 'info', + `Found ${count} non-JSON / non-XML file(s) in directory: ${folder}. Only resource files with JSON or XML extensions are supported. To see the skipped files in the logs, use debug logging.` + ); + } + }); + } + + getPackageJSON(): PackageJSON { + return this.packageJSON; + } + + getResourceByKey(key: string) { + if (this.registeredKeys.has(key)) { + return this.getResourceAtPath(key); + } + throw new Error(`Unregistered resource key: ${key}`); + } + + private getResourceAtPath(key: string) { + let resource: any; + if (/.xml$/i.test(key)) { + let xml: string; + try { + xml = fs.readFileSync(key).toString(); + } catch { + throw new Error(`Failed to get XML resource at path ${key}`); + } + try { + // TODO: Support other versions of FHIR during conversion + resource = this.fhirConverter.xmlToObj(xml); + } catch (e) { + throw new Error(`Failed to convert XML resource at path ${key}: ${e.message}`); + } + } else if (/.json$/i.test(key)) { + try { + resource = fs.readJSONSync(key); + } catch { + throw new Error(`Failed to get JSON resource at path ${key}`); + } + } else { + throw new Error(`Failed to find XML or JSON file at path ${key}`); + } + return resource; + } +} + +function getFilePaths(paths: string[], recursive: boolean): string[] { + const filePaths = new Set(); + paths.forEach(p => { + const stat = fs.statSync(p); + if (stat.isFile()) { + filePaths.add(path.resolve(p)); + } else if (stat.isDirectory()) { + fs.readdirSync(p, { withFileTypes: true }).forEach(entry => { + if (entry.isFile()) { + filePaths.add(path.resolve(entry.parentPath, entry.name)); + } else if (recursive && entry.isDirectory()) { + getFilePaths([path.resolve(entry.parentPath, entry.name)], recursive).forEach(fp => + filePaths.add(fp) + ); + } + }); + } + }); + return Array.from(filePaths); +} diff --git a/src/virtual/InMemoryVirtualPackage.ts b/src/virtual/InMemoryVirtualPackage.ts new file mode 100644 index 0000000..dcaa4ab --- /dev/null +++ b/src/virtual/InMemoryVirtualPackage.ts @@ -0,0 +1,50 @@ +import { PackageJSON } from '../package/PackageJSON'; +import { LogFunction } from '../utils/logger'; +import { VirtualPackage, VirtualPackageOptions } from './VirtualPackage'; + +export class InMemoryVirtualPackage implements VirtualPackage { + private log: LogFunction; + private allowNonResources: boolean; + private registeredResources: Set; + + constructor( + private packageJSON: PackageJSON, + private resources: Map, + options: VirtualPackageOptions = {} + ) { + this.log = options.log ?? (() => {}); + this.allowNonResources = options.allowNonResources ?? false; + this.registeredResources = new Set(); + } + + async registerResources( + register: (key: string, resource: any, allowNonResources?: boolean) => void + ): Promise { + this.resources.forEach((resource, key) => { + try { + register(key, resource, this.allowNonResources); + this.registeredResources.add(key); + } catch (e) { + this.log('error', `Failed to register resource with key: ${key}`); + if (e.stack) { + this.log('debug', e.stack); + } + } + }); + } + + getPackageJSON(): PackageJSON { + return this.packageJSON; + } + + getResourceByKey(key: string) { + if (this.registeredResources.has(key)) { + const resource = this.resources.get(key); + if (!resource) { + throw new Error(`Could not find in-memory resource with key: ${key}`); + } + return resource; + } + throw new Error(`Unregistered resource key: ${key}`); + } +} diff --git a/src/virtual/VirtualPackage.ts b/src/virtual/VirtualPackage.ts new file mode 100644 index 0000000..b32a8d5 --- /dev/null +++ b/src/virtual/VirtualPackage.ts @@ -0,0 +1,15 @@ +import { LogFunction } from '../utils/logger'; +import { PackageJSON } from '../package/PackageJSON'; + +export interface VirtualPackage { + registerResources( + register: (key: string, resource: any, allowNonResources?: boolean) => void + ): Promise; + getPackageJSON(): PackageJSON; + getResourceByKey(key: string): any; +} + +export type VirtualPackageOptions = { + log?: LogFunction; + allowNonResources?: boolean; +}; diff --git a/src/virtual/index.ts b/src/virtual/index.ts new file mode 100644 index 0000000..92d6340 --- /dev/null +++ b/src/virtual/index.ts @@ -0,0 +1,3 @@ +export * from './VirtualPackage'; +export * from './DiskBasedVirtualPackage'; +export * from './InMemoryVirtualPackage'; diff --git a/test/cache/DiskBasedPackageCache.test.ts b/test/cache/DiskBasedPackageCache.test.ts index 974b42d..e99ec91 100644 --- a/test/cache/DiskBasedPackageCache.test.ts +++ b/test/cache/DiskBasedPackageCache.test.ts @@ -9,19 +9,17 @@ temp.track(); describe('DiskBasedPackageCache', () => { const cacheFolder = path.resolve(__dirname, 'fixtures', 'fhircache'); - const local1Folder = path.resolve(__dirname, 'fixtures', 'local', 'local1'); - const local2Folder = path.resolve(__dirname, 'fixtures', 'local', 'local2'); let cache: DiskBasedPackageCache; beforeEach(() => { - cache = new DiskBasedPackageCache(cacheFolder, [], { log: loggerSpy.log }); + cache = new DiskBasedPackageCache(cacheFolder, { log: loggerSpy.log }); loggerSpy.reset(); }); describe('#cachePackageTarball', () => { it('should cache a fhir package tarball to the cache folder', async () => { const tempCacheFolder = temp.mkdirSync('fpl-test'); - const tempCache = new DiskBasedPackageCache(tempCacheFolder, [], { log: loggerSpy.log }); + const tempCache = new DiskBasedPackageCache(tempCacheFolder, { log: loggerSpy.log }); const tarballStream = fs.createReadStream( path.join(__dirname, 'fixtures', 'tarballs', 'small-package.tgz') ); @@ -46,7 +44,7 @@ describe('DiskBasedPackageCache', () => { it('should clean a malformed fhir package tarball when caching it', async () => { const tempCacheFolder = temp.mkdirSync('fpl-test'); - const tempCache = new DiskBasedPackageCache(tempCacheFolder, [], { log: loggerSpy.log }); + const tempCache = new DiskBasedPackageCache(tempCacheFolder, { log: loggerSpy.log }); // NOTE: This package has example, xml, package, and other folders at root const tarballStream = fs.createReadStream( path.join(__dirname, 'fixtures', 'tarballs', 'small-wrong-package.tgz') @@ -95,10 +93,6 @@ describe('DiskBasedPackageCache', () => { it('should return false for a package with different version in the cache', () => { expect(cache.isPackageInCache('fhir.small', '0.2.0')).toBeFalsy(); }); - - it('should always return true for the special LOCAL#LOCAL package', () => { - expect(cache.isPackageInCache('LOCAL', 'LOCAL')).toBeTruthy(); - }); }); describe('#getPackagePath', () => { @@ -115,22 +109,6 @@ describe('DiskBasedPackageCache', () => { it('should return undefined for a package with different version in the cache', () => { expect(cache.getPackagePath('fhir.small', '0.2.0')).toBeUndefined(); }); - - it('should return empty path for the special LOCAL#LOCAL package when no resource folders are configured', () => { - expect(cache.getPackagePath('LOCAL', 'LOCAL')).toBe(''); - }); - - it('should return single path for the special LOCAL#LOCAL package when one resource folder is configured', () => { - const cacheWithFolder = new DiskBasedPackageCache(cacheFolder, [local1Folder]); - expect(cacheWithFolder.getPackagePath('LOCAL', 'LOCAL')).toBe(local1Folder); - }); - - it('should return semi-colon-separated path for the special LOCAL#LOCAL package when multiple resource folders are configured', () => { - const cacheWithFolder = new DiskBasedPackageCache(cacheFolder, [local1Folder, local2Folder]); - expect(cacheWithFolder.getPackagePath('LOCAL', 'LOCAL')).toBe( - `${local1Folder};${local2Folder}` - ); - }); }); describe('#getPackageJSONPath', () => { @@ -147,10 +125,6 @@ describe('DiskBasedPackageCache', () => { it('should return undefined for a package with different version in the cache', () => { expect(cache.getPackageJSONPath('fhir.small', '0.2.0')).toBeUndefined(); }); - - it('should return undefined for the special LOCAL#LOCAL package', () => { - expect(cache.getPackageJSONPath('LOCAL', 'LOCAL')).toBeUndefined(); - }); }); describe('#getPotentialResourcePaths', () => { @@ -175,59 +149,6 @@ describe('DiskBasedPackageCache', () => { expect(potentials).toHaveLength(0); expect(loggerSpy.getAllLogs()).toHaveLength(0); }); - - it('should return potential paths for the special LOCAL#LOCAL package', () => { - const cacheWithFolder = new DiskBasedPackageCache(cacheFolder, [local1Folder, local2Folder], { - log: loggerSpy.log - }); - const potentials = cacheWithFolder.getPotentialResourcePaths('LOCAL', 'LOCAL'); - expect(potentials).toHaveLength(12); - expect(potentials).toContain(path.resolve(local1Folder, 'CodeSystem-a-to-d.json')); - expect(potentials).toContain(path.resolve(local1Folder, 'CodeSystem-x-to-z.xml')); - expect(potentials).toContain( - path.resolve(local1Folder, 'StructureDefinition-family-member.json') - ); - expect(potentials).toContain( - path.resolve(local1Folder, 'StructureDefinition-human-being-logical-model.json') - ); - expect(potentials).toContain( - path.resolve(local1Folder, 'StructureDefinition-true-false.xml') - ); - expect(potentials).toContain( - path.resolve(local1Folder, 'StructureDefinition-valued-observation.json') - ); - expect(potentials).toContain(path.resolve(local1Folder, 'ValueSet-beginning-and-end.json')); - expect(potentials).toContain(path.resolve(local2Folder, 'Binary-LogicalModelExample.json')); - expect(potentials).toContain(path.resolve(local2Folder, 'Binary-LogicalModelExample.xml')); - expect(potentials).toContain(path.resolve(local2Folder, 'Observation-A1Example.xml')); - expect(potentials).toContain(path.resolve(local2Folder, 'Observation-B2Example.json')); - expect(potentials).toContain(path.resolve(local2Folder, 'Patient-JamesPondExample.json')); - expect(loggerSpy.getAllLogs('debug')).toHaveLength(3); - expect( - loggerSpy - .getAllMessages('debug') - .some(m => /^Skipped spreadsheet XML file: .*resources-spreadsheet\.xml$/.test(m)) - ).toBeTruthy(); - expect( - loggerSpy - .getAllMessages('debug') - .some(m => - /^Skipped spreadsheet XML file: .*sneaky-spread-like-bread-sheet\.xml$/.test(m) - ) - ).toBeTruthy(); - expect( - loggerSpy - .getAllMessages('debug') - .some(m => /^Skipped non-JSON \/ non-XML file: .*not-a-resource\.txt$/.test(m)) - ).toBeTruthy(); - expect(loggerSpy.getAllLogs('info')).toHaveLength(2); - expect(loggerSpy.getFirstMessage('info')).toMatch( - /Found 2 spreadsheet\(s\) in directory: .*local1\./ - ); - expect(loggerSpy.getLastMessage('info')).toMatch( - /Found 1 non-JSON \/ non-XML file\(s\) in directory: .*local2\./ - ); - }); }); describe('#getResourceAtPath', () => { @@ -239,42 +160,5 @@ describe('DiskBasedPackageCache', () => { expect(resource.id).toBe('MyPatient'); expect(loggerSpy.getAllLogs('error')).toHaveLength(0); }); - - it('should return a resource with an xml path where xml was converted to a resource', () => { - const totalPath = path.resolve(local1Folder, 'StructureDefinition-true-false.xml'); - const resource = cache.getResourceAtPath(totalPath); - expect(resource).toBeDefined(); - expect(resource.id).toBe('true-false'); - expect(resource.xml).toBeUndefined(); - expect(loggerSpy.getAllLogs('error')).toHaveLength(0); - }); - - it('should throw error when path points to a xml file that does not exist', () => { - const totalPath = path.resolve(local1Folder, 'example-file-that-doesnt-exist.xml'); - expect(() => { - cache.getResourceAtPath(totalPath); - }).toThrow(/Failed to get XML resource at path/); - }); - - it('should throw error when path points to a json file that does not exist', () => { - const totalPath = path.resolve(local1Folder, 'example-file-that-doesnt-exist.json'); - expect(() => { - cache.getResourceAtPath(totalPath); - }).toThrow(/Failed to get JSON resource at path/); - }); - - it('should throw error when path points to an invalid file type that is not json or xml', () => { - const totalPath = path.resolve(local1Folder, 'example-file-that-doesnt-exist.txt'); - expect(() => { - cache.getResourceAtPath(totalPath); - }).toThrow(/Failed to find XML or JSON file/); - }); - - it('should throw error when path points to a file that does not exist', () => { - const totalPath = path.resolve(local1Folder, ''); - expect(() => { - cache.getResourceAtPath(totalPath); - }).toThrow(/Failed to find XML or JSON file/); - }); }); }); diff --git a/test/current/BuildDotFhirDotOrgClient.test.ts b/test/current/BuildDotFhirDotOrgClient.test.ts index ca6f3b3..402242b 100644 --- a/test/current/BuildDotFhirDotOrgClient.test.ts +++ b/test/current/BuildDotFhirDotOrgClient.test.ts @@ -107,43 +107,43 @@ describe('BuildDotFhirDotOrgClient', () => { }); it ('should download the most current package from the main branch when no branch given', async () => { - const latest = await client.downloadCurrentBuild('simple.hl7.fhir.us.core.r4', null); + const latest = await client.downloadCurrentBuild('simple.hl7.fhir.us.core.r4'); expect(loggerSpy.getLastMessage('info')).toBe('Attempting to download simple.hl7.fhir.us.core.r4#current from https://build.fhir.org/ig/HL7/simple-US-Core-R4/branches/main/package.tgz'); expect(latest).toBeInstanceOf(Readable); expect(latest.read()).toBe('simple zipfile'); }); it ('should try to download the current package from master branch if not specified', async () => { - const latest = await client.downloadCurrentBuild('sushi-test', null); + const latest = await client.downloadCurrentBuild('sushi-test'); expect(loggerSpy.getLastMessage('info')).toBe('Attempting to download sushi-test#current from https://build.fhir.org/ig/sushi/sushi-test/branches/master/package.tgz'); expect(latest).toBeInstanceOf(Readable); expect(latest.read()).toBe('sushi-test master zip file'); }); it ('should download the most current package when a current package version has multiple versions', async () => { - const latest = await client.downloadCurrentBuild('hl7.fhir.us.core.r4', null); + const latest = await client.downloadCurrentBuild('hl7.fhir.us.core.r4'); expect(loggerSpy.getLastMessage('info')).toBe('Attempting to download hl7.fhir.us.core.r4#current from https://build.fhir.org/ig/HL7/US-Core-R4/branches/main/package.tgz'); expect(latest).toBeInstanceOf(Readable); expect(latest.read()).toBe('current multiple version zip file'); }); it ('should throw error when invalid package name (unknown name) given', async () => { - const latest = client.downloadCurrentBuild('invalid.pkg.name', null); + const latest = client.downloadCurrentBuild('invalid.pkg.name'); await expect(latest).rejects.toThrow(/The package invalid.pkg.name#current is not available/); }); it ('should throw error when invalid package name (empty string) given', async () => { - const latest = client.downloadCurrentBuild('', null); + const latest = client.downloadCurrentBuild(''); await expect(latest).rejects.toThrow(/The package #current is not available/); }); it ('should not try to download the latest package from a branch that is not main/master if one is not available', async () => { - const latest = client.downloadCurrentBuild('sushi-no-main', null); + const latest = client.downloadCurrentBuild('sushi-no-main'); await expect(latest).rejects.toThrow(/The package sushi-no-main#current is not available/); }); it ('should throw error if able to find current build base url, but downloading does not find matching package', async () => { - const latest = client.downloadCurrentBuild('sushi-test-no-download', null); + const latest = client.downloadCurrentBuild('sushi-test-no-download'); await expect(latest).rejects.toThrow('Failed to download sushi-test-no-download#current from https://build.fhir.org/ig/sushi/sushi-test-no-download/branches/master/package.tgz'); }); }); @@ -274,17 +274,17 @@ describe('BuildDotFhirDotOrgClient', () => { }); it ('should throw error if download has 404 status', async () => { - const latest = client.downloadCurrentBuild('sushi-test-bad-status', null); + const latest = client.downloadCurrentBuild('sushi-test-bad-status'); await expect(latest).rejects.toThrow('Failed to download sushi-test-bad-status#current from https://build.fhir.org/ig/sushi/sushi-test-bad-status/branches/master/package.tgz'); }); it ('should throw error if download has no data', async () => { - const latest = client.downloadCurrentBuild('sushi-test-no-data', null); + const latest = client.downloadCurrentBuild('sushi-test-no-data'); await expect(latest).rejects.toThrow('Failed to download sushi-test-no-data#current from https://build.fhir.org/ig/sushi/sushi-test-no-data/branches/master/package.tgz'); }); it ('should throw error if download is unsuccessful', async () => { - const latest = client.downloadCurrentBuild('test-nodownload', null); + const latest = client.downloadCurrentBuild('test-nodownload'); await expect(latest).rejects.toThrow(/The package test-nodownload#current is not available/); }); }); diff --git a/test/db/SQLJSPackageDB.test.ts b/test/db/SQLJSPackageDB.test.ts index e9fa39e..0e16cdb 100644 --- a/test/db/SQLJSPackageDB.test.ts +++ b/test/db/SQLJSPackageDB.test.ts @@ -2,6 +2,7 @@ import initSqlJs from 'sql.js'; import { SQLJSPackageDB } from '../../src/db/SQLJSPackageDB'; import { loggerSpy } from '../testhelpers'; import { ResourceInfo } from '../../src/package'; +import { byLoadOrder, byType } from '../../src/sort'; describe('SQLJSPackageDB', () => { let SQL: initSqlJs.SqlJsStatic; @@ -356,6 +357,7 @@ describe('SQLJSPackageDB', () => { resourceType: 'StructureDefinition', id: 'my-patient-profile', name: 'MyPatientProfile', + version: '3.2.2', sdKind: 'resource', sdDerivation: 'constraint', sdType: 'Patient', @@ -368,6 +370,7 @@ describe('SQLJSPackageDB', () => { resourceType: 'StructureDefinition', id: 'my-observation-profile', name: 'MyObservationProfile', + version: '3.2.3', sdKind: 'resource', sdFlavor: 'Profile', packageName: 'SecretPackage', @@ -377,6 +380,7 @@ describe('SQLJSPackageDB', () => { resourceType: 'StructureDefinition', id: 'a-special-extension', name: 'SpecialExtension', + version: '3.2.2', url: 'http://example.org/Extensions/a-special-extension', sdFlavor: 'Extension', packageName: 'RegularPackage', @@ -387,6 +391,7 @@ describe('SQLJSPackageDB', () => { id: 'my-value-set', url: 'http://example.org/ValueSets/my-value-set', name: 'MyValueSet', + version: '3.2.2', packageName: 'RegularPackage', packageVersion: '3.2.2' }; @@ -395,6 +400,7 @@ describe('SQLJSPackageDB', () => { id: 'my-value-set', url: 'http://example.org/ValueSets/my-value-set', name: 'MyValueSet', + version: '4.5.6', packageName: 'RegularPackage', packageVersion: '4.5.6' }; @@ -437,6 +443,53 @@ describe('SQLJSPackageDB', () => { expect(resources).toContainEqual(expect.objectContaining(valueSetFour)); }); + it('should find resources where the key matches the resource id and the canonical version matches', () => { + const resources = packageDb.findResourceInfos('my-value-set|3.2.2'); + expect(resources).toHaveLength(1); + expect(resources).toContainEqual(expect.objectContaining(valueSetThree)); + + const resources2 = packageDb.findResourceInfos('my-value-set|4.5.6'); + expect(resources2).toHaveLength(1); + expect(resources2).toContainEqual(expect.objectContaining(valueSetFour)); + }); + + it('should find resources where the key matches the resource name and the canonical version matches', () => { + const resources = packageDb.findResourceInfos('MyValueSet|3.2.2'); + expect(resources).toHaveLength(1); + expect(resources).toContainEqual(expect.objectContaining(valueSetThree)); + + const resources2 = packageDb.findResourceInfos('MyValueSet|4.5.6'); + expect(resources2).toHaveLength(1); + expect(resources2).toContainEqual(expect.objectContaining(valueSetFour)); + }); + + it('should find resources where the key matches the resource url and the canonical version matches', () => { + const resources = packageDb.findResourceInfos( + 'http://example.org/ValueSets/my-value-set|3.2.2' + ); + expect(resources).toHaveLength(1); + expect(resources).toContainEqual(expect.objectContaining(valueSetThree)); + + const resources2 = packageDb.findResourceInfos( + 'http://example.org/ValueSets/my-value-set|4.5.6' + ); + expect(resources2).toHaveLength(1); + expect(resources2).toContainEqual(expect.objectContaining(valueSetFour)); + }); + + it('should find no matches when the canonical version does not match', () => { + const resources = packageDb.findResourceInfos('my-value-set|9.9.9'); + expect(resources).toHaveLength(0); + + const resources2 = packageDb.findResourceInfos('MyValueSet|9.9.9'); + expect(resources2).toHaveLength(0); + + const resources3 = packageDb.findResourceInfos( + 'http://example.org/ValueSets/my-value-set|9.9.9' + ); + expect(resources3).toHaveLength(0); + }); + it('should find resources that match a package name', () => { const resources = packageDb.findResourceInfos('*', { scope: 'RegularPackage' @@ -473,6 +526,173 @@ describe('SQLJSPackageDB', () => { }); expect(resources).toHaveLength(2); }); + + it('should sort results using the ascending order in which resources were loaded (e.g., first in first out) when no sort order is passed in', () => { + const resources = packageDb.findResourceInfos('*'); + expect(resources).toHaveLength(5); + expect(resources).toEqual([ + expect.objectContaining(patientProfile), + expect.objectContaining(observationProfile), + expect.objectContaining(specialExtension), + expect.objectContaining(valueSetThree), + expect.objectContaining(valueSetFour) + ]); + }); + + it('should sort results using the ascending order in which resources were loaded (e.g., first in first out) when sort order is ByLoadOrder ascending', () => { + const resources = packageDb.findResourceInfos('*', { sort: [byLoadOrder(true)] }); + expect(resources).toHaveLength(5); + expect(resources).toEqual([ + expect.objectContaining(patientProfile), + expect.objectContaining(observationProfile), + expect.objectContaining(specialExtension), + expect.objectContaining(valueSetThree), + expect.objectContaining(valueSetFour) + ]); + }); + + it('should sort results using the descending order in which resources were loaded (e.g., last in first out) when sort order is ByLoadOrder descending', () => { + const resources = packageDb.findResourceInfos('*', { sort: [byLoadOrder(false)] }); + expect(resources).toHaveLength(5); + expect(resources).toEqual([ + expect.objectContaining(valueSetFour), + expect.objectContaining(valueSetThree), + expect.objectContaining(specialExtension), + expect.objectContaining(observationProfile), + expect.objectContaining(patientProfile) + ]); + }); + + it('should sort results using the ascending order of passed in types, then ascending order in which resources were loaded when sort order is ByType', () => { + const resources = packageDb.findResourceInfos('*', { + type: ['StructureDefinition', 'ValueSet'], + sort: [byType('StructureDefinition', 'ValueSet')] + }); + expect(resources).toHaveLength(5); + expect(resources).toEqual([ + expect.objectContaining(patientProfile), + expect.objectContaining(observationProfile), + expect.objectContaining(specialExtension), + expect.objectContaining(valueSetThree), + expect.objectContaining(valueSetFour) + ]); + + // Try it both directions to ensure that options.type is not influencing order + const resources2 = packageDb.findResourceInfos('*', { + type: ['StructureDefinition', 'ValueSet'], + sort: [byType('ValueSet', 'StructureDefinition')] + }); + expect(resources2).toHaveLength(5); + expect(resources2).toEqual([ + expect.objectContaining(valueSetThree), + expect.objectContaining(valueSetFour), + expect.objectContaining(patientProfile), + expect.objectContaining(observationProfile), + expect.objectContaining(specialExtension) + ]); + }); + + it('should sort results using the ascending order of passed in types, then ascending order in which resources were loaded when sort order is ByType and ByLoadOrder ascending', () => { + const resources = packageDb.findResourceInfos('*', { + type: ['StructureDefinition', 'ValueSet'], + sort: [byType('StructureDefinition', 'ValueSet'), byLoadOrder()] + }); + expect(resources).toHaveLength(5); + expect(resources).toEqual([ + expect.objectContaining(patientProfile), + expect.objectContaining(observationProfile), + expect.objectContaining(specialExtension), + expect.objectContaining(valueSetThree), + expect.objectContaining(valueSetFour) + ]); + + // Try it both directions to ensure that options.type is not influencing order + const resources2 = packageDb.findResourceInfos('*', { + type: ['StructureDefinition', 'ValueSet'], + sort: [byType('ValueSet', 'StructureDefinition')] + }); + expect(resources2).toHaveLength(5); + expect(resources2).toEqual([ + expect.objectContaining(valueSetThree), + expect.objectContaining(valueSetFour), + expect.objectContaining(patientProfile), + expect.objectContaining(observationProfile), + expect.objectContaining(specialExtension) + ]); + }); + + it('should sort results using the ascending order of passed in types, then descending order in which resources were saved when sort order is ByType and ByLoadOrder descending', () => { + const resources = packageDb.findResourceInfos('*', { + type: ['StructureDefinition', 'ValueSet'], + sort: [byType('StructureDefinition', 'ValueSet'), byLoadOrder(false)] + }); + expect(resources).toHaveLength(5); + expect(resources).toEqual([ + expect.objectContaining(specialExtension), + expect.objectContaining(observationProfile), + expect.objectContaining(patientProfile), + expect.objectContaining(valueSetFour), + expect.objectContaining(valueSetThree) + ]); + + // Try it both directions to ensure that options.type is not influencing order + const resources2 = packageDb.findResourceInfos('*', { + type: ['StructureDefinition', 'ValueSet'], + sort: [byType('ValueSet', 'StructureDefinition'), byLoadOrder(false)] + }); + expect(resources2).toHaveLength(5); + expect(resources2).toEqual([ + expect.objectContaining(valueSetFour), + expect.objectContaining(valueSetThree), + expect.objectContaining(specialExtension), + expect.objectContaining(observationProfile), + expect.objectContaining(patientProfile) + ]); + }); + + it('should support SD flavors when sorting results using passed in types', () => { + const resources = packageDb.findResourceInfos('*', { + type: ['Profile', 'ValueSet', 'Extension'], + sort: [byType('Profile', 'ValueSet', 'Extension')] + }); + expect(resources).toHaveLength(5); + expect(resources).toEqual([ + expect.objectContaining(patientProfile), + expect.objectContaining(observationProfile), + expect.objectContaining(valueSetThree), + expect.objectContaining(valueSetFour), + expect.objectContaining(specialExtension) + ]); + + // Try it both directions to ensure that options.type is not influencing order + const resources2 = packageDb.findResourceInfos('*', { + type: ['Profile', 'ValueSet', 'Extension'], + sort: [byType('Extension', 'ValueSet', 'Profile')] + }); + expect(resources2).toHaveLength(5); + expect(resources2).toEqual([ + expect.objectContaining(specialExtension), + expect.objectContaining(valueSetThree), + expect.objectContaining(valueSetFour), + expect.objectContaining(patientProfile), + expect.objectContaining(observationProfile) + ]); + }); + + it('should put non-ordered types at end when sorting results using passed in types', () => { + const resources = packageDb.findResourceInfos('*', { + type: ['Profile', 'ValueSet', 'Extension'], + sort: [byType('ValueSet', 'Profile')] + }); + expect(resources).toHaveLength(5); + expect(resources).toEqual([ + expect.objectContaining(valueSetThree), + expect.objectContaining(valueSetFour), + expect.objectContaining(patientProfile), + expect.objectContaining(observationProfile), + expect.objectContaining(specialExtension) + ]); + }); }); describe('#findResourceInfo', () => { @@ -482,6 +702,7 @@ describe('SQLJSPackageDB', () => { id: 'a-special-extension', name: 'SpecialExtension', url: 'http://example.org/Extensions/a-special-extension', + version: '4.5.6', sdFlavor: 'Extension', packageName: 'RegularPackage', packageVersion: '4.5.6' @@ -491,6 +712,7 @@ describe('SQLJSPackageDB', () => { id: 'my-value-set', url: 'http://example.org/ValueSets/my-value-set', name: 'MyValueSet', + version: '3.2.2', packageName: 'RegularPackage', packageVersion: '3.2.2' }; @@ -499,6 +721,7 @@ describe('SQLJSPackageDB', () => { id: 'my-value-set', url: 'http://example.org/ValueSets/my-value-set', name: 'MyValueSet', + version: '4.5.6', packageName: 'RegularPackage', packageVersion: '4.5.6' }; @@ -510,18 +733,128 @@ describe('SQLJSPackageDB', () => { packageDb.saveResourceInfo(valueSetFour); }); - it('should return one resource when there is at least one match', () => { + it('should return one resource when there is at least one match by resource id', () => { const resource = packageDb.findResourceInfo('my-value-set'); expect(resource).toBeDefined(); // both valueSetThree and valueSetFour have a matching id, + // but the first resource added wins. + expect(resource).toEqual(expect.objectContaining(valueSetThree)); + }); + + it('should return one resource when there is at least one match by resource name', () => { + const resource = packageDb.findResourceInfo('MyValueSet'); + expect(resource).toBeDefined(); + // both valueSetThree and valueSetFour have a matching id, + // but the first resource added wins. + expect(resource).toEqual(expect.objectContaining(valueSetThree)); + }); + + it('should return one resource when there is at least one match by resource url', () => { + const resource = packageDb.findResourceInfo('http://example.org/ValueSets/my-value-set'); + expect(resource).toBeDefined(); + // both valueSetThree and valueSetFour have a matching id, + // but the first resource added wins. + expect(resource).toEqual(expect.objectContaining(valueSetThree)); + }); + + it('should return one resource when there is at least one match by resource id and the canonical version matches', () => { + const resource = packageDb.findResourceInfo('my-value-set|3.2.2'); + expect(resource).toBeDefined(); + expect(resource).toEqual(expect.objectContaining(valueSetThree)); + + const resource2 = packageDb.findResourceInfo('my-value-set|4.5.6'); + expect(resource2).toBeDefined(); + expect(resource2).toEqual(expect.objectContaining(valueSetFour)); + }); + + it('should return one resource when there is at least one match by resource name and the canonical version matches', () => { + const resource = packageDb.findResourceInfo('MyValueSet|3.2.2'); + expect(resource).toBeDefined(); + expect(resource).toEqual(expect.objectContaining(valueSetThree)); + + const resource2 = packageDb.findResourceInfo('MyValueSet|4.5.6'); + expect(resource2).toBeDefined(); + expect(resource2).toEqual(expect.objectContaining(valueSetFour)); + }); + + it('should return one resource when there is at least one match by resource url and the canonical version matches', () => { + const resource = packageDb.findResourceInfo( + 'http://example.org/ValueSets/my-value-set|3.2.2' + ); + expect(resource).toBeDefined(); + expect(resource).toEqual(expect.objectContaining(valueSetThree)); + + const resource2 = packageDb.findResourceInfo( + 'http://example.org/ValueSets/my-value-set|4.5.6' + ); + expect(resource2).toBeDefined(); + expect(resource2).toEqual(expect.objectContaining(valueSetFour)); + }); + + it('should return the first loaded resource when there is at least one match and sorted ByLoadOrder ascending', () => { + const resource = packageDb.findResourceInfo('my-value-set', { sort: [byLoadOrder()] }); + expect(resource).toBeDefined(); + // both valueSetThree and valueSetFour have a matching id, + // but the first resource added wins. + expect(resource).toEqual(expect.objectContaining(valueSetThree)); + }); + + it('should return the last loaded resource when there is at least one match and sorted ByLoadOrder descending', () => { + const resource = packageDb.findResourceInfo('my-value-set', { + sort: [byLoadOrder(false)] + }); + expect(resource).toBeDefined(); + // both valueSetThree and valueSetFour have a matching id, // but the last resource added wins. expect(resource).toEqual(expect.objectContaining(valueSetFour)); }); + it('should return first loaded resource for wildcard search when no types are passed in', () => { + const resource = packageDb.findResourceInfo('*'); + expect(resource).toEqual(expect.objectContaining(specialExtension)); + }); + + it('should return first loaded resource for wildcard search when no types are passed in and sorted ByLoadOrder ascending', () => { + const resource = packageDb.findResourceInfo('*', { sort: [byLoadOrder()] }); + expect(resource).toEqual(expect.objectContaining(specialExtension)); + }); + + it('should return last loaded resource for wildcard search when no types are passed in and sorted ByLoadOrder descending', () => { + const resource = packageDb.findResourceInfo('*', { sort: [byLoadOrder(false)] }); + expect(resource).toEqual(expect.objectContaining(valueSetFour)); + }); + + it('should return resource of first matching type when types are passed in', () => { + const resource = packageDb.findResourceInfo('*', { + type: ['StructureDefinition', 'ValueSet'] + }); + expect(resource).toEqual(expect.objectContaining(specialExtension)); + }); + + it('should support SD flavors when types are passed in', () => { + const resource = packageDb.findResourceInfo('*', { + type: ['Profile', 'Extension', 'ValueSet'] + }); + expect(resource).toEqual(expect.objectContaining(specialExtension)); + }); + it('should return undefined when there are no matches', () => { const resource = packageDb.findResourceInfo('nonexistent-profile'); expect(resource).toBeUndefined(); }); + + it('should return undefined when the canonical version does not match', () => { + const resource = packageDb.findResourceInfo('my-value-set|9.9.9'); + expect(resource).toBeUndefined(); + + const resource2 = packageDb.findResourceInfo('MyValueSet|9.9.9'); + expect(resource2).toBeUndefined(); + + const resource3 = packageDb.findResourceInfo( + 'http://example.org/ValueSets/my-value-set|9.9.9' + ); + expect(resource3).toBeUndefined(); + }); }); describe('#getPackageStats', () => { @@ -588,4 +921,24 @@ describe('SQLJSPackageDB', () => { expect(result).toBeUndefined(); }); }); + + describe('#exportDB', () => { + let packageDb: SQLJSPackageDB; + beforeEach(() => { + packageDb = new SQLJSPackageDB(sqlDb); + packageDb.savePackageInfo({ + name: 'CookiePackage', + version: '3.2.2', + packagePath: '/var/data/.fhir/CookiePackage-3.2.2' + }); + }); + + it('should return an object with the correct mimetype and some data', async () => { + const result = await packageDb.exportDB(); + expect(result).toBeDefined(); + expect(result.mimeType).toEqual('application/x-sqlite3'); + // Testing the actual export data for correctness would be tedious, so just check that it's there + expect(result.data).toBeDefined(); + }); + }); }); diff --git a/test/loader/BasePackageLoader.test.ts b/test/loader/BasePackageLoader.test.ts index 5b13d30..b02ddd6 100644 --- a/test/loader/BasePackageLoader.test.ts +++ b/test/loader/BasePackageLoader.test.ts @@ -9,6 +9,7 @@ import { RegistryClient } from '../../src/registry'; import { CurrentBuildClient } from '../../src/current'; import { loggerSpy } from '../testhelpers'; import fs from 'fs-extra'; +import { VirtualPackage } from '../../src/package'; describe('BasePackageLoader', () => { let loader: BasePackageLoader; @@ -28,6 +29,14 @@ describe('BasePackageLoader', () => { BasePackageLoader.prototype as any, 'loadPackageFromCache' ); + registryClientMock.resolveVersion.mockImplementation((name, version) => { + if (version === 'latest') { + return Promise.resolve('9.9.9'); + } else if (/^\d+\.\d+\.x$/.test(version)) { + return Promise.resolve(version.replace(/x$/, '9')); + } + return Promise.resolve(version); + }); loader = new BasePackageLoader( packageDBMock, packageCacheMock, @@ -125,13 +134,13 @@ describe('BasePackageLoader', () => { .calledWith(pkg.name, 'current') .mockReturnValueOnce(pkg.packageJsonPath); packageCacheMock.getResourceAtPath - .calledWith(pkg.packageJsonPath) - .mockReturnValueOnce(pkg.resourcePath); + .calledWith(pkg.resourcePath) + .mockReturnValueOnce(pkg.resourceInfo); currentBuildClientMock.getCurrentBuildDate .calledWith(pkg.name, undefined) .mockReturnValueOnce(pkg.currentBuildDate); currentBuildClientMock.downloadCurrentBuild - .calledWith(pkg.name, 'current') + .calledWith(pkg.name) .mockResolvedValue(pkg.tarball); loadPackageFromCacheSpy.mockReturnValueOnce({ name: pkg.name, @@ -178,13 +187,12 @@ describe('BasePackageLoader', () => { const result = await loader.loadPackage('some.ig', 'current'); expect(result).toBe(LoadStatus.FAILED); expect(loader.getPackageLoadStatus).toHaveBeenCalledWith('some.ig', 'current'); - expect(loggerSpy.getMessageAtIndex(-1, 'debug')).toBe( + expect(loggerSpy.getLastMessage('debug')).toBe( 'Cached package date for some.ig#current (2024-08-24T23:02:27) does not match last build date (2020-08-24T23:02:27)' ); - expect(loggerSpy.getMessageAtIndex(-2, 'error')).toBe( - 'Failed to download some.ig#current from current builds' + expect(loggerSpy.getLastMessage('error')).toBe( + 'Failed to load some.ig#current: Failed to download most recent some.ig#current from current builds' ); - expect(loggerSpy.getMessageAtIndex(-1, 'error')).toBe('Failed to load some.ig#current'); expect(loadPackageFromCacheSpy).toHaveBeenCalledWith('some.ig', 'current'); }); @@ -213,7 +221,7 @@ describe('BasePackageLoader', () => { expect(currentBuildClientMock.downloadCurrentBuild).not.toHaveBeenCalled(); expect(packageCacheMock.cachePackageTarball).not.toHaveBeenCalled(); expect(loadPackageFromCacheSpy).toHaveBeenCalledWith('some.ig', 'current'); - expect(loggerSpy.getMessageAtIndex(-1, 'debug')).toBe( + expect(loggerSpy.getLastMessage('debug')).toBe( 'Cached package date for some.ig#current (2024-08-24T23:02:27) matches last build date (2024-08-24T23:02:27), so the cached package will be used' ); expect(loggerSpy.getLastMessage('info')).toBe('Loaded some.ig#current with 5 resources'); @@ -239,7 +247,7 @@ describe('BasePackageLoader', () => { const result = await loader.loadPackage('some.ig', 'current$bonus-items'); expect(loader.getPackageLoadStatus).toHaveBeenCalledWith('some.ig', 'current$bonus-items'); - expect(loggerSpy.getMessageAtIndex(-1, 'debug')).toBe( + expect(loggerSpy.getLastMessage('debug')).toBe( 'Cached package date for some.ig#current$bonus-items (2024-08-24T23:02:27) matches last build date (2024-08-24T23:02:27), so the cached package will be used' ); expect(currentBuildClientMock.downloadCurrentBuild).not.toHaveBeenCalled(); @@ -270,38 +278,42 @@ describe('BasePackageLoader', () => { it('should load a patch versioned package from the registry when it is not in the cache', async () => { const pkg = setupLoadPackage('some.ig', '1.2.x', 'not-loaded'); - packageCacheMock.isPackageInCache - .calledWith(pkg.name, pkg.version) - .mockReturnValueOnce(false); - registryClientMock.download.calledWith(pkg.name, pkg.version).mockResolvedValue(pkg.tarball); + // default mock always changes .x to .9 + packageCacheMock.isPackageInCache.calledWith(pkg.name, '1.2.9').mockReturnValueOnce(false); + registryClientMock.download.calledWith(pkg.name, '1.2.9').mockResolvedValue(pkg.tarball); loadPackageFromCacheSpy.mockReturnValueOnce({ name: pkg.name, - version: '1.2.3', + version: '1.2.9', resourceCount: 5 }); const result = await loader.loadPackage(pkg.name, pkg.version); expect(result).toBe(LoadStatus.LOADED); - expect(loggerSpy.getLastMessage('info')).toBe('Loaded some.ig#1.2.3 with 5 resources'); - expect(loadPackageFromCacheSpy).toHaveBeenCalledWith('some.ig', '1.2.x'); + expect(loggerSpy.getMessageAtIndex(-2, 'info')).toBe( + 'Resolved some.ig#1.2.x to concrete version 1.2.9' + ); + expect(loggerSpy.getLastMessage('info')).toBe('Loaded some.ig#1.2.9 with 5 resources'); + expect(loadPackageFromCacheSpy).toHaveBeenCalledWith('some.ig', '1.2.9'); }); it('should load a latest versioned package from the registry when it is not in the cache', async () => { const pkg = setupLoadPackage('some.ig', 'latest', 'not-loaded'); - packageCacheMock.isPackageInCache - .calledWith(pkg.name, pkg.version) - .mockReturnValueOnce(false); - registryClientMock.download.calledWith(pkg.name, pkg.version).mockResolvedValue(pkg.tarball); + // default mock always changes latest to 9.9.9 + packageCacheMock.isPackageInCache.calledWith(pkg.name, '9.9.9').mockReturnValueOnce(false); + registryClientMock.download.calledWith(pkg.name, '9.9.9').mockResolvedValue(pkg.tarball); loadPackageFromCacheSpy.mockReturnValueOnce({ name: pkg.name, - version: '1.2.3', + version: '9.9.9', resourceCount: 5 }); const result = await loader.loadPackage(pkg.name, pkg.version); expect(result).toBe(LoadStatus.LOADED); - expect(loggerSpy.getLastMessage('info')).toBe('Loaded some.ig#1.2.3 with 5 resources'); - expect(loadPackageFromCacheSpy).toHaveBeenCalledWith('some.ig', 'latest'); + expect(loggerSpy.getMessageAtIndex(-2, 'info')).toBe( + 'Resolved some.ig#latest to concrete version 9.9.9' + ); + expect(loggerSpy.getLastMessage('info')).toBe('Loaded some.ig#9.9.9 with 5 resources'); + expect(loadPackageFromCacheSpy).toHaveBeenCalledWith('some.ig', '9.9.9'); }); it('should log error when a versioned package is unable to be downloaded from the registry', async () => { @@ -319,10 +331,9 @@ describe('BasePackageLoader', () => { const result = await loader.loadPackage(pkg.name, pkg.version); expect(result).toBe(LoadStatus.FAILED); - expect(loggerSpy.getMessageAtIndex(-2, 'error')).toBe( - 'Failed to download some.ig#1.2.3 from registry' + expect(loggerSpy.getLastMessage('error')).toBe( + 'Failed to load some.ig#1.2.3: Failed to download some.ig#1.2.3 from the registry' ); - expect(loggerSpy.getLastMessage('error')).toBe('Failed to load some.ig#1.2.3'); expect(loadPackageFromCacheSpy).toHaveBeenCalledWith('some.ig', '1.2.3'); }); @@ -347,7 +358,7 @@ describe('BasePackageLoader', () => { .calledWith(pkg.name, undefined) .mockReturnValueOnce(pkg.currentBuildDate); currentBuildClientMock.downloadCurrentBuild - .calledWith(pkg.name, 'current') + .calledWith(pkg.name) .mockResolvedValue(pkg.tarball); loadPackageFromCacheSpy.mockReturnValue({ name: pkg.name, @@ -359,7 +370,7 @@ describe('BasePackageLoader', () => { expect(packageCacheMock.getPackageJSONPath).toHaveBeenCalledWith('some.ig', 'current'); expect(packageCacheMock.getResourceAtPath).toHaveBeenCalled(); expect(currentBuildClientMock.getCurrentBuildDate).toHaveBeenCalled(); - expect(loggerSpy.getMessageAtIndex(-1, 'debug')).toBe( + expect(loggerSpy.getLastMessage('debug')).toBe( 'Cached package date for some.ig#current (2024-08-24T23:02:27) does not match last build date (2020-08-24T23:02:27)' ); expect(loggerSpy.getMessageAtIndex(-2, 'info')).toBe( @@ -382,7 +393,7 @@ describe('BasePackageLoader', () => { .calledWith(pkg.name, undefined) .mockReturnValueOnce(pkg.currentBuildDate); currentBuildClientMock.downloadCurrentBuild - .calledWith(pkg.name, 'current') + .calledWith(pkg.name) .mockResolvedValue(pkg.tarball); loadPackageFromCacheSpy.mockReturnValue({ name: pkg.name, @@ -391,7 +402,7 @@ describe('BasePackageLoader', () => { }); await loader.loadPackage('some.ig', 'current'); - expect(loggerSpy.getMessageAtIndex(-1, 'debug')).toContain('2024-08-24T23:02:27'); + expect(loggerSpy.getLastMessage('debug')).toContain('2024-08-24T23:02:27'); }); it('should catch error and assume stale version if packageJSONPath was not found', async () => { @@ -400,7 +411,7 @@ describe('BasePackageLoader', () => { .calledWith(pkg.name, 'current') .mockReturnValueOnce(undefined); currentBuildClientMock.downloadCurrentBuild - .calledWith(pkg.name, 'current') + .calledWith(pkg.name) .mockResolvedValue(pkg.tarball); loadPackageFromCacheSpy.mockReturnValue({ name: pkg.name, @@ -427,7 +438,7 @@ describe('BasePackageLoader', () => { .calledWith(pkg.packageJsonPath) .mockReturnValueOnce(undefined); currentBuildClientMock.downloadCurrentBuild - .calledWith(pkg.name, 'current') + .calledWith(pkg.name) .mockResolvedValue(pkg.tarball); loadPackageFromCacheSpy.mockReturnValueOnce({ name: pkg.name, @@ -445,6 +456,52 @@ describe('BasePackageLoader', () => { expect(result).toBe(LoadStatus.LOADED); }); + it('should log an error but load cached version if it cannot download current package when current version in cache out of date', async () => { + const pkg = setupLoadPackage( + 'some.ig', + 'current', + 'not-loaded', + undefined, + undefined, + undefined, + '20200824230227' + ); + packageCacheMock.getPackageJSONPath + .calledWith(pkg.name, 'current') + .mockReturnValueOnce(pkg.packageJsonPath); + packageCacheMock.getResourceAtPath + .calledWith(pkg.packageJsonPath) + .mockReturnValueOnce(pkg.resourceInfo); + currentBuildClientMock.getCurrentBuildDate + .calledWith(pkg.name, undefined) + .mockReturnValueOnce(pkg.currentBuildDate); + currentBuildClientMock.downloadCurrentBuild + .calledWith(pkg.name) + .mockRejectedValue(new Error('failed download')); + loadPackageFromCacheSpy.mockReturnValue({ + name: pkg.name, + version: pkg.version, + resourceCount: 4 + }); + + const result = await loader.loadPackage('some.ig', 'current'); + expect(packageCacheMock.getPackageJSONPath).toHaveBeenCalledWith('some.ig', 'current'); + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalled(); + expect(currentBuildClientMock.getCurrentBuildDate).toHaveBeenCalled(); + expect(loggerSpy.getLastMessage('debug')).toBe( + 'Cached package date for some.ig#current (2024-08-24T23:02:27) does not match last build date (2020-08-24T23:02:27)' + ); + expect(loggerSpy.getMessageAtIndex(-2, 'info')).toBe( + 'Cached package some.ig#current is out of date and will be replaced by the most recent current build.' + ); + expect(loggerSpy.getLastMessage('error')).toBe( + 'Failed to download most recent some.ig#current from current builds. Using most recent cached package instead.' + ); + expect(loggerSpy.getLastMessage('info')).toBe('Loaded some.ig#current with 4 resources'); + expect(loadPackageFromCacheSpy).toHaveBeenCalledWith('some.ig', 'current'); + expect(result).toBe(LoadStatus.LOADED); + }); + it('should throw error if package is not cached on final load', async () => { const humanBeingResourcePath = path.resolve( __dirname, @@ -467,7 +524,7 @@ describe('BasePackageLoader', () => { const result = await loader.loadPackage('human-being-logical-model', '1.0.0'); expect(result).toBe(LoadStatus.FAILED); - expect(loggerSpy.getMessageAtIndex(-1, 'error')).toBe( + expect(loggerSpy.getLastMessage('error')).toBe( 'Failed to load human-being-logical-model#1.0.0' ); expect(packageCacheMock.getPackagePath).not.toHaveBeenCalled(); @@ -501,41 +558,13 @@ describe('BasePackageLoader', () => { expect(result).toBe(LoadStatus.LOADED); }); - it('should use LOCAL package paths to find package', async () => { + it('should use non local package paths to find package with logical flavor that uses the logical-target extension to set its characteristics', async () => { const fixturePath = setupPackageWithFixture( - 'LOCAL', - 'LOCAL', - 'StructureDefinition-human-being-logical-model.json' - ); - const result = await loader.loadPackage('LOCAL', 'LOCAL'); - expect(packageDBMock.savePackageInfo).toHaveBeenCalled(); - expect(packageDBMock.saveResourceInfo).toHaveBeenCalledWith({ - resourceType: 'StructureDefinition', - id: 'human-being-logical-model', - url: 'http://example.org/fhir/locals/StructureDefinition/human-being-logical-model', - name: 'Human', - version: '1.0.0', - sdKind: 'logical', - sdDerivation: 'specialization', - sdType: 'http://example.org/fhir/locals/StructureDefinition/human-being-logical-model', - sdBaseDefinition: 'http://hl7.org/fhir/StructureDefinition/Base', - sdAbstract: false, - sdCharacteristics: ['can-be-target'], - sdFlavor: 'Logical', - packageName: 'LOCAL', - packageVersion: 'LOCAL', - resourcePath: fixturePath - }); - expect(result).toBe(LoadStatus.LOADED); - }); - - it('should find a logical that uses the logical-target extension to set its characteristics', async () => { - const fixturePath = setupPackageWithFixture( - 'LOCAL', - 'LOCAL', + 'futureplanet', + '1.0.0', 'StructureDefinition-FuturePlanet.json' ); - const result = await loader.loadPackage('LOCAL', 'LOCAL'); + const result = await loader.loadPackage('futureplanet', '1.0.0'); expect(packageDBMock.savePackageInfo).toHaveBeenCalled(); expect(packageDBMock.saveResourceInfo).toHaveBeenCalledWith({ resourceType: 'StructureDefinition', @@ -549,8 +578,8 @@ describe('BasePackageLoader', () => { sdAbstract: false, sdCharacteristics: ['can-be-target'], sdFlavor: 'Logical', - packageName: 'LOCAL', - packageVersion: 'LOCAL', + packageName: 'futureplanet', + packageVersion: '1.0.0', resourcePath: fixturePath }); expect(result).toBe(LoadStatus.LOADED); @@ -776,15 +805,19 @@ describe('BasePackageLoader', () => { packageCacheMock.getPotentialResourcePaths .calledWith(pkg.name, pkg.version) .mockReturnValue([ - path.join(pkg.packageJsonPath, '1.json'), - path.join(pkg.packageJsonPath, '2.json') + path.join(pkg.packageBasePath, 'package', '1.json'), + path.join(pkg.packageBasePath, 'package', '2.json'), + path.join(pkg.packageBasePath, 'package', 'package.json') ]); packageCacheMock.getResourceAtPath - .calledWith(path.join(pkg.packageJsonPath, '1.json')) + .calledWith(path.join(pkg.packageBasePath, 'package', '1.json')) .mockReturnValue(birthPlaceJSON); packageCacheMock.getResourceAtPath - .calledWith(path.join(pkg.packageJsonPath, '2.json')) + .calledWith(path.join(pkg.packageBasePath, 'package', '2.json')) .mockReturnValue({ blanket: 'cozy' }); + packageCacheMock.getResourceAtPath + .calledWith(path.join(pkg.packageBasePath, 'package', 'package.json')) + .mockReturnValue({ name: 'package-with-non-resource', version: '4.0.1' }); packageDBMock.getPackageStats .calledWith(pkg.name, pkg.version) .mockReturnValue({ name: pkg.name, version: pkg.version, resourceCount: 1 }); @@ -792,8 +825,13 @@ describe('BasePackageLoader', () => { const result = await loader.loadPackage('package-with-non-resource', '4.0.1'); // expect debug message for 2.json expect(loggerSpy.getLastMessage('debug')).toBe( - `JSON file at path ${path.join(pkg.packageJsonPath, '2.json')} was not FHIR resource` + `JSON file at path ${path.join(pkg.packageBasePath, 'package', '2.json')} was not FHIR resource` ); + // it should not log a debug message for package.json since it is so common + const debugLoggedPackageJSON = loggerSpy + .getAllMessages('debug') + .some(m => /package\.json was not FHIR resource/.test(m)); + expect(debugLoggedPackageJSON).toBeFalsy(); expect(packageDBMock.savePackageInfo).toHaveBeenCalled(); expect(packageDBMock.saveResourceInfo).toHaveBeenCalledTimes(1); expect(packageDBMock.saveResourceInfo).toHaveBeenCalledWith({ @@ -810,12 +848,338 @@ describe('BasePackageLoader', () => { sdFlavor: 'Extension', packageName: 'package-with-non-resource', packageVersion: '4.0.1', - resourcePath: path.join(pkg.packageJsonPath, '1.json') + resourcePath: path.join(pkg.packageBasePath, 'package', '1.json') }); expect(result).toBe(LoadStatus.LOADED); }); }); + describe('#loadVirtualPackage', () => { + it('should return LOADED when the package is already loaded', async () => { + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'my-vp', version: '1.1.1' }); + vPackMock.registerResources.mockResolvedValue(); + vPackMock.getResourceByKey.mockReturnValue({}); + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.LOADED); + + const result = await loader.loadVirtualPackage(vPackMock); + expect(result).toBe(LoadStatus.LOADED); + expect(loader.getPackageLoadStatus).toHaveBeenCalledWith('my-vp', '1.1.1'); + expect(packageDBMock.savePackageInfo).not.toHaveBeenCalled(); + }); + + it('should load a virtual package and save registered resources to the database', async () => { + const logicalKey = 'StructureDefinition-human-being-logical-model.json'; + const logicalPath = path.resolve( + __dirname, + 'fixtures', + 'StructureDefinition-human-being-logical-model.json' + ); + const logicalJSON = await fs.readJSON(logicalPath); + const profileKey = 'StructureDefinition-named-and-gendered-patient.json'; + const profilePath = path.resolve( + __dirname, + 'fixtures', + 'StructureDefinition-named-and-gendered-patient.json' + ); + const profileJSON = await fs.readJSON(profilePath); + + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'my-vp', version: '1.1.1' }); + vPackMock.registerResources.mockImplementation( + (register: (key: string, resource: any, allowNonResources?: boolean) => void) => { + register(logicalKey, logicalJSON, true); + register(profileKey, profileJSON, true); + return Promise.resolve(); + } + ); + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + packageDBMock.getPackageStats + .calledWith('my-vp', '1.1.1') + .mockReturnValue({ name: 'my-vp', version: '1.1.1', resourceCount: 2 }); + + const result = await loader.loadVirtualPackage(vPackMock); + expect(result).toBe(LoadStatus.LOADED); + expect(packageDBMock.savePackageInfo).toHaveBeenCalledWith({ + name: 'my-vp', + version: '1.1.1', + packagePath: 'virtual:my-vp#1.1.1', + packageJSONPath: 'virtual:my-vp#1.1.1:package.json' + }); + expect(packageDBMock.saveResourceInfo).toHaveBeenNthCalledWith(1, { + resourceType: 'StructureDefinition', + id: 'human-being-logical-model', + url: 'http://example.org/fhir/locals/StructureDefinition/human-being-logical-model', + name: 'Human', + version: '1.0.0', + sdKind: 'logical', + sdDerivation: 'specialization', + sdType: 'http://example.org/fhir/locals/StructureDefinition/human-being-logical-model', + sdBaseDefinition: 'http://hl7.org/fhir/StructureDefinition/Base', + sdAbstract: false, + sdCharacteristics: ['can-be-target'], + sdFlavor: 'Logical', + packageName: 'my-vp', + packageVersion: '1.1.1', + resourcePath: 'virtual:my-vp#1.1.1:StructureDefinition-human-being-logical-model.json' + }); + expect(packageDBMock.saveResourceInfo).toHaveBeenNthCalledWith(2, { + resourceType: 'StructureDefinition', + id: 'named-and-gendered-patient', + url: 'http://example.org/impose/StructureDefinition/named-and-gendered-patient', + name: 'NamedAndGenderedPatient', + version: '0.1.0', + sdKind: 'resource', + sdDerivation: 'constraint', + sdType: 'Patient', + sdBaseDefinition: 'http://hl7.org/fhir/StructureDefinition/Patient', + sdAbstract: false, + sdFlavor: 'Profile', + sdImposeProfiles: [ + 'http://example.org/impose/StructureDefinition/named-patient', + 'http://example.org/impose/StructureDefinition/gendered-patient' + ], + packageName: 'my-vp', + packageVersion: '1.1.1', + resourcePath: 'virtual:my-vp#1.1.1:StructureDefinition-named-and-gendered-patient.json' + }); + }); + + it('should load a virtual package and save non-resource instances if allowed', async () => { + const logicalInstanceKey = 'CustomModel-1.json'; + const logicalInstanceJSON = { hello: 'world' }; + const logicalInstance2Key = 'CustomModel-2.json'; + const logicalInstance2JSON = { goodnight: 'moon' }; + + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'my-vp', version: '1.1.2' }); + vPackMock.registerResources.mockImplementation( + (register: (key: string, resource: any, allowNonResources?: boolean) => void) => { + register(logicalInstanceKey, logicalInstanceJSON, true); + register(logicalInstance2Key, logicalInstance2JSON, true); + return Promise.resolve(); + } + ); + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + packageDBMock.getPackageStats + .calledWith('my-vp', '1.1.2') + .mockReturnValue({ name: 'my-vp', version: '1.1.2', resourceCount: 2 }); + + const result = await loader.loadVirtualPackage(vPackMock); + expect(result).toBe(LoadStatus.LOADED); + expect(packageDBMock.savePackageInfo).toHaveBeenCalledWith({ + name: 'my-vp', + version: '1.1.2', + packagePath: 'virtual:my-vp#1.1.2', + packageJSONPath: 'virtual:my-vp#1.1.2:package.json' + }); + expect(packageDBMock.saveResourceInfo).toHaveBeenNthCalledWith(1, { + resourceType: 'Unknown', + packageName: 'my-vp', + packageVersion: '1.1.2', + resourcePath: 'virtual:my-vp#1.1.2:CustomModel-1.json' + }); + expect(packageDBMock.saveResourceInfo).toHaveBeenNthCalledWith(2, { + resourceType: 'Unknown', + packageName: 'my-vp', + packageVersion: '1.1.2', + resourcePath: 'virtual:my-vp#1.1.2:CustomModel-2.json' + }); + }); + + it('should load a virtual package and skip non-resource instances if not allowed', async () => { + const logicalInstanceKey = 'CustomModel-1.json'; + const logicalInstanceJSON = { hello: 'world' }; + const profileKey = 'StructureDefinition-named-and-gendered-patient.json'; + const profilePath = path.resolve( + __dirname, + 'fixtures', + 'StructureDefinition-named-and-gendered-patient.json' + ); + const profileJSON = await fs.readJSON(profilePath); + + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'my-vp', version: '1.1.3' }); + let logicalException: any, profileException: any; + vPackMock.registerResources.mockImplementation( + (register: (key: string, resource: any, allowNonResources?: boolean) => void) => { + try { + register(logicalInstanceKey, logicalInstanceJSON, false); + } catch (e) { + logicalException = e; + } + try { + register(profileKey, profileJSON, false); + } catch (e) { + profileException = e; + } + return Promise.resolve(); + } + ); + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + packageDBMock.getPackageStats + .calledWith('my-vp', '1.1.3') + .mockReturnValue({ name: 'my-vp', version: '1.1.3', resourceCount: 1 }); + + const result = await loader.loadVirtualPackage(vPackMock); + expect(result).toBe(LoadStatus.LOADED); + expect(packageDBMock.savePackageInfo).toHaveBeenCalledWith({ + name: 'my-vp', + version: '1.1.3', + packagePath: 'virtual:my-vp#1.1.3', + packageJSONPath: 'virtual:my-vp#1.1.3:package.json' + }); + expect(logicalException).toBeDefined(); + expect(logicalException.toString()).toMatch( + 'The resource at virtual:my-vp#1.1.3:CustomModel-1.json is not a valid FHIR resource: resource does not specify its resourceType.' + ); + expect(profileException).toBeUndefined(); + expect(packageDBMock.saveResourceInfo).toHaveBeenCalledTimes(1); + expect(packageDBMock.saveResourceInfo).toHaveBeenCalledWith({ + resourceType: 'StructureDefinition', + id: 'named-and-gendered-patient', + url: 'http://example.org/impose/StructureDefinition/named-and-gendered-patient', + name: 'NamedAndGenderedPatient', + version: '0.1.0', + sdKind: 'resource', + sdDerivation: 'constraint', + sdType: 'Patient', + sdBaseDefinition: 'http://hl7.org/fhir/StructureDefinition/Patient', + sdAbstract: false, + sdFlavor: 'Profile', + sdImposeProfiles: [ + 'http://example.org/impose/StructureDefinition/named-patient', + 'http://example.org/impose/StructureDefinition/gendered-patient' + ], + packageName: 'my-vp', + packageVersion: '1.1.3', + resourcePath: 'virtual:my-vp#1.1.3:StructureDefinition-named-and-gendered-patient.json' + }); + }); + + it('should load a virtual package and save registered resources to the database', async () => { + const logicalKey = 'StructureDefinition-human-being-logical-model.json'; + const logicalPath = path.resolve( + __dirname, + 'fixtures', + 'StructureDefinition-human-being-logical-model.json' + ); + const logicalJSON = await fs.readJSON(logicalPath); + const profileKey = 'StructureDefinition-named-and-gendered-patient.json'; + const profilePath = path.resolve( + __dirname, + 'fixtures', + 'StructureDefinition-named-and-gendered-patient.json' + ); + const profileJSON = await fs.readJSON(profilePath); + + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'my-vp', version: '1.1.1' }); + vPackMock.registerResources.mockImplementation( + (register: (key: string, resource: any, allowNonResources?: boolean) => void) => { + register(logicalKey, logicalJSON, true); + register(profileKey, profileJSON, true); + return Promise.resolve(); + } + ); + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + packageDBMock.getPackageStats + .calledWith('my-vp', '1.1.1') + .mockReturnValue({ name: 'my-vp', version: '1.1.1', resourceCount: 2 }); + + const result = await loader.loadVirtualPackage(vPackMock); + expect(result).toBe(LoadStatus.LOADED); + expect(packageDBMock.savePackageInfo).toHaveBeenCalledWith({ + name: 'my-vp', + version: '1.1.1', + packagePath: 'virtual:my-vp#1.1.1', + packageJSONPath: 'virtual:my-vp#1.1.1:package.json' + }); + expect(packageDBMock.saveResourceInfo).toHaveBeenNthCalledWith(1, { + resourceType: 'StructureDefinition', + id: 'human-being-logical-model', + url: 'http://example.org/fhir/locals/StructureDefinition/human-being-logical-model', + name: 'Human', + version: '1.0.0', + sdKind: 'logical', + sdDerivation: 'specialization', + sdType: 'http://example.org/fhir/locals/StructureDefinition/human-being-logical-model', + sdBaseDefinition: 'http://hl7.org/fhir/StructureDefinition/Base', + sdAbstract: false, + sdCharacteristics: ['can-be-target'], + sdFlavor: 'Logical', + packageName: 'my-vp', + packageVersion: '1.1.1', + resourcePath: 'virtual:my-vp#1.1.1:StructureDefinition-human-being-logical-model.json' + }); + expect(packageDBMock.saveResourceInfo).toHaveBeenNthCalledWith(2, { + resourceType: 'StructureDefinition', + id: 'named-and-gendered-patient', + url: 'http://example.org/impose/StructureDefinition/named-and-gendered-patient', + name: 'NamedAndGenderedPatient', + version: '0.1.0', + sdKind: 'resource', + sdDerivation: 'constraint', + sdType: 'Patient', + sdBaseDefinition: 'http://hl7.org/fhir/StructureDefinition/Patient', + sdAbstract: false, + sdFlavor: 'Profile', + sdImposeProfiles: [ + 'http://example.org/impose/StructureDefinition/named-patient', + 'http://example.org/impose/StructureDefinition/gendered-patient' + ], + packageName: 'my-vp', + packageVersion: '1.1.1', + resourcePath: 'virtual:my-vp#1.1.1:StructureDefinition-named-and-gendered-patient.json' + }); + }); + + it('should not load a virtual package that does not specify name', async () => { + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: '', version: '1.2.4' }); + vPackMock.registerResources.mockResolvedValue(); + vPackMock.getResourceByKey.mockReturnValue({}); + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + + const result = await loader.loadVirtualPackage(vPackMock); + expect(result).toBe(LoadStatus.FAILED); + expect(loggerSpy.getLastMessage('error')).toBe( + 'Failed to load virtual package #1.2.4 because the provided packageJSON did not have a valid name and/or version' + ); + expect(packageDBMock.savePackageInfo).not.toHaveBeenCalled(); + }); + + it('should not load a virtual package that does not specify version', async () => { + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'my-vp', version: '' }); + vPackMock.registerResources.mockResolvedValue(); + vPackMock.getResourceByKey.mockReturnValue({}); + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + + const result = await loader.loadVirtualPackage(vPackMock); + expect(result).toBe(LoadStatus.FAILED); + expect(loggerSpy.getLastMessage('error')).toBe( + 'Failed to load virtual package my-vp# because the provided packageJSON did not have a valid name and/or version' + ); + expect(packageDBMock.savePackageInfo).not.toHaveBeenCalled(); + }); + + it('should log an error and report loading failure when a virtual package throws an exception registering resources', async () => { + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'my-vp', version: '1.2.5' }); + vPackMock.registerResources.mockRejectedValue(new Error('Unexpected exception!')); + vPackMock.getResourceByKey.mockReturnValue({}); + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + + const result = await loader.loadVirtualPackage(vPackMock); + expect(result).toBe(LoadStatus.FAILED); + expect(loggerSpy.getLastMessage('error')).toBe( + 'Virtual package my-vp#1.2.5 threw an exception while registering resources, so it was only partially loaded.' + ); + expect(packageDBMock.savePackageInfo).toHaveBeenCalled(); + }); + }); + describe('#getPackageLoadStatus', () => { it('should return LOADED status if package was previously loaded', async () => { const name = 'some.ig'; @@ -883,18 +1247,70 @@ describe('BasePackageLoader', () => { ]); packageCacheMock.getResourceAtPath .calledWith('/first/package/package.json') - .mockReturnValueOnce({ id: 'some.ig', version: '1.2.3' }); + .mockReturnValueOnce({ name: 'some.ig', version: '1.2.3' }); packageCacheMock.getResourceAtPath .calledWith('/second/package/package.json') - .mockReturnValueOnce({ id: 'some.ig', version: '2.3.4' }); + .mockReturnValueOnce({ name: 'some.ig', version: '2.3.4' }); packageCacheMock.getResourceAtPath .calledWith('/third/package/package.json') - .mockReturnValueOnce({ id: 'some.ig', version: '3.4.5' }); + .mockReturnValueOnce({ name: 'some.ig', version: '3.4.5' }); const result = loader.findPackageJSONs(name); expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(2); expect(result).toHaveLength(2); - expect(result[0]).toEqual({ id: 'some.ig', version: '1.2.3' }); - expect(result[1]).toEqual({ id: 'some.ig', version: '3.4.5' }); + expect(result[0]).toEqual({ name: 'some.ig', version: '1.2.3' }); + expect(result[1]).toEqual({ name: 'some.ig', version: '3.4.5' }); + }); + + it('should return package json array including virtual packages', () => { + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + // Virtual Package 1 + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'some.ig', version: '1.2.3' }); + vPackMock.registerResources.mockResolvedValue(); + packageDBMock.getPackageStats + .calledWith('some.ig', '1.2.3') + .mockReturnValue({ name: 'some.ig', version: '1.2.3', resourceCount: 0 }); + loader.loadVirtualPackage(vPackMock); + // Virtual Package 2 + const vPackMock2 = mock(); + vPackMock2.getPackageJSON.mockReturnValue({ name: 'some.ig', version: '2.3.4' }); + vPackMock2.registerResources.mockResolvedValue(); + packageDBMock.getPackageStats + .calledWith('some.ig', '2.3.4') + .mockReturnValue({ name: 'some.ig', version: '2.3.4', resourceCount: 0 }); + loader.loadVirtualPackage(vPackMock2); + // Normal (non-virtual) Package + packageCacheMock.getResourceAtPath + .calledWith('/third/package/package.json') + .mockReturnValueOnce({ name: 'some.ig', version: '3.4.5' }); + + const name = 'some.ig'; + packageDBMock.findPackageInfos.calledWith(name).mockReturnValueOnce([ + { + name: 'some.ig', + version: '1.2.3', + packageJSONPath: 'virtual:some.ig#1.2.3:package.json' + }, + { + name: 'some.ig', + version: '2.3.4', + packageJSONPath: 'virtual:some.ig#2.3.4:package.json' + }, + { + name: 'some.ig', + version: '3.4.5', + packageJSONPath: '/third/package/package.json' + } + ]); + + const result = loader.findPackageJSONs(name); + expect(vPackMock.getPackageJSON).toHaveBeenCalledTimes(2); // once at registration and once at find + expect(vPackMock2.getPackageJSON).toHaveBeenCalledTimes(2); // once at registration and once at find + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ name: 'some.ig', version: '1.2.3' }); + expect(result[1]).toEqual({ name: 'some.ig', version: '2.3.4' }); + expect(result[2]).toEqual({ name: 'some.ig', version: '3.4.5' }); }); }); @@ -920,6 +1336,31 @@ describe('BasePackageLoader', () => { }); }); + it('should return package json for virtual package', () => { + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ + name: 'some.ig', + version: '1.2.3', + date: '20240824230227' + }); + vPackMock.registerResources.mockResolvedValue(); + packageDBMock.getPackageStats + .calledWith('some.ig', '1.2.3') + .mockReturnValue({ name: 'some.ig', version: '1.2.3', resourceCount: 0 }); + loader.loadVirtualPackage(vPackMock); + + packageDBMock.findPackageInfo.calledWith('some.ig', '1.2.3').mockReturnValueOnce({ + name: 'some.ig', + version: '1.2.3', + packageJSONPath: 'virtual:some.ig#1.2.3:package.json' + }); + + const result = loader.findPackageJSON('some.ig', '1.2.3'); + expect(vPackMock.getPackageJSON).toHaveBeenCalledTimes(2); // once at registration and once at find + expect(result).toEqual({ name: 'some.ig', version: '1.2.3', date: '20240824230227' }); + }); + it('should return undefined when the info does not contain a packageJSONPath', () => { const name = 'some.ig'; const version = '1.2.3'; @@ -1035,6 +1476,180 @@ describe('BasePackageLoader', () => { { id: 'third-thing', version: '1.2.3' } ]); }); + + it('should return resource json array with resources from virtual packages', () => { + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + // Virtual Package 1 + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'some.ig', version: '1.2.3' }); + vPackMock.registerResources.mockResolvedValue(); + vPackMock.getResourceByKey.calledWith('firstResource.json').mockReturnValue({ + id: '1', + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3' + }); + packageDBMock.getPackageStats + .calledWith('some.ig', '1.2.3') + .mockReturnValue({ name: 'some.ig', version: '1.2.3', resourceCount: 1 }); + loader.loadVirtualPackage(vPackMock); + // Virtual Package 2 + const vPackMock2 = mock(); + vPackMock2.getPackageJSON.mockReturnValue({ name: 'some.ig', version: '2.3.4' }); + vPackMock2.registerResources.mockResolvedValue(); + vPackMock2.getResourceByKey.calledWith('secondResource.json').mockReturnValue({ + id: '2', + name: 'secondResource', + resourceType: 'ValueSet', + version: '1.2.3' + }); + packageDBMock.getPackageStats + .calledWith('some.ig', '2.3.4') + .mockReturnValue({ name: 'some.ig', version: '2.3.4', resourceCount: 1 }); + loader.loadVirtualPackage(vPackMock2); + // Resource from normal (non-virtual) Package + packageCacheMock.getResourceAtPath + .calledWith('/some/package/third-thing.json') + .mockReturnValueOnce({ + id: '3', + name: 'third-thing', + resourceType: 'CodeSystem', + version: '1.2.3' + }); + + const resourceInfos = [ + { + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3', + resourcePath: 'virtual:some.ig#1.2.3:firstResource.json' + }, + { + name: 'secondResource', + resourceType: 'ValueSet', + version: '1.2.3', + resourcePath: 'virtual:some.ig#2.3.4:secondResource.json' + }, + { + name: 'thirdResource', + resourceType: 'CodeSystem', + version: '1.2.3', + resourcePath: '/some/package/third-thing.json' + } + ]; + packageDBMock.findResourceInfos.calledWith('*').mockReturnValueOnce(resourceInfos); + + const result = loader.findResourceJSONs('*'); + expect(result).toEqual([ + { + id: '1', + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3' + }, + { + id: '2', + name: 'secondResource', + resourceType: 'ValueSet', + version: '1.2.3' + }, + { + id: '3', + name: 'third-thing', + resourceType: 'CodeSystem', + version: '1.2.3' + } + ]); + }); + + it('should use LRU cache for resource json in subsequent results', () => { + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + // Virtual Package + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'some.ig', version: '1.2.3' }); + vPackMock.registerResources.mockResolvedValue(); + vPackMock.getResourceByKey.calledWith('firstResource.json').mockReturnValue({ + id: '1', + name: 'firstResource' + }); + vPackMock.getResourceByKey.calledWith('secondResource.json').mockReturnValue({ + id: '2', + name: 'secondResource' + }); + packageDBMock.getPackageStats + .calledWith('some.ig', '1.2.3') + .mockReturnValue({ name: 'some.ig', version: '1.2.3', resourceCount: 1 }); + loader.loadVirtualPackage(vPackMock); + // Resource from normal (non-virtual) Package + packageCacheMock.getResourceAtPath + .calledWith('/some/package/third-thing.json') + .mockReturnValue({ + id: '3', + name: 'third-thing' + }); + + const resourceInfos = [ + { + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3', + resourcePath: 'virtual:some.ig#1.2.3:firstResource.json' + }, + { + name: 'secondResource', + resourceType: 'ValueSet', + version: '1.2.3', + resourcePath: 'virtual:some.ig#1.2.3:secondResource.json' + }, + { + name: 'thirdResource', + resourceType: 'CodeSystem', + version: '1.2.3', + resourcePath: '/some/package/third-thing.json' + } + ]; + packageDBMock.findResourceInfos + .calledWith('firstTwo') + .mockReturnValue(resourceInfos.slice(0, 2)); + packageDBMock.findResourceInfos.calledWith('*').mockReturnValue(resourceInfos); + + // First call for first two, should call the virtual package but not the package cache + expect(vPackMock.getResourceByKey).toHaveBeenCalledTimes(0); + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(0); + const result = loader.findResourceJSONs('firstTwo'); + expect(result).toEqual([ + { id: '1', name: 'firstResource' }, + { id: '2', name: 'secondResource' } + ]); + expect(vPackMock.getResourceByKey).toHaveBeenCalledTimes(2); + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(0); + // Second call for first two, should not call the virtual package nor the package cache + const result2 = loader.findResourceJSONs('firstTwo'); + expect(result2).toEqual([ + { id: '1', name: 'firstResource' }, + { id: '2', name: 'secondResource' } + ]); + expect(vPackMock.getResourceByKey).toHaveBeenCalledTimes(2); // still just 2 + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(0); // still not called + // New call for all three, should not call the virtual package but should call the package cache + const result3 = loader.findResourceJSONs('*'); + expect(result3).toEqual([ + { id: '1', name: 'firstResource' }, + { id: '2', name: 'secondResource' }, + { id: '3', name: 'third-thing' } + ]); + expect(vPackMock.getResourceByKey).toHaveBeenCalledTimes(2); // still just 2 + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(1); // now called once + // One more call for all three, should not call the virtual package nor the package cache + const result4 = loader.findResourceJSONs('*'); + expect(result4).toEqual([ + { id: '1', name: 'firstResource' }, + { id: '2', name: 'secondResource' }, + { id: '3', name: 'third-thing' } + ]); + expect(vPackMock.getResourceByKey).toHaveBeenCalledTimes(2); // still just 2 + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(1); // still just 1 + }); }); describe('#findResourceJSON', () => { @@ -1052,6 +1667,148 @@ describe('BasePackageLoader', () => { expect(result).toEqual({ id: 'first-thing', version: '1.2.3' }); }); + it('should use the LRU cache for resource JSON on subsequent retrievals of the same resource', () => { + packageDBMock.findResourceInfo.mockReturnValue({ + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3', + resourcePath: '/some/package/first-thing.json' + }); + packageCacheMock.getResourceAtPath + .calledWith('/some/package/first-thing.json') + .mockReturnValue({ id: 'first-thing', version: '1.2.3' }); + // First call, should get it from the package cache (typically disk-based) + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(0); + const result = loader.findResourceJSON('firstResource'); + expect(result).toEqual({ id: 'first-thing', version: '1.2.3' }); + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(1); + // Second call, should not call the package cache again + const result2 = loader.findResourceJSON('firstResource'); + expect(result2).toEqual({ id: 'first-thing', version: '1.2.3' }); + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(1); // still 1 + // Third call, using a different key, but resolves to same resource, should not call the package cache again + const result3 = loader.findResourceJSON('first-thing'); + expect(result3).toEqual({ id: 'first-thing', version: '1.2.3' }); + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(1); // still 1 + }); + + it('should not use the LRU cache for resource JSON when cache size is 0', () => { + // Re-assign loader to an instance with cache-size 0 + loader = new BasePackageLoader( + packageDBMock, + packageCacheMock, + registryClientMock, + currentBuildClientMock, + { log: loggerSpy.log, resourceCacheSize: 0 } + ); + packageDBMock.findResourceInfo.mockReturnValue({ + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3', + resourcePath: '/some/package/first-thing.json' + }); + packageCacheMock.getResourceAtPath + .calledWith('/some/package/first-thing.json') + .mockReturnValue({ id: 'first-thing', version: '1.2.3' }); + // First call, should get it from the package cache (typically disk-based) + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(0); + const result = loader.findResourceJSON('firstResource'); + expect(result).toEqual({ id: 'first-thing', version: '1.2.3' }); + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(1); + // Second call, should still get it from the package cache (no LRU cache) + const result2 = loader.findResourceJSON('firstResource'); + expect(result2).toEqual({ id: 'first-thing', version: '1.2.3' }); + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(2); // called again + // Third call, using a different key, but resolves to same resource, should still get it from the package cache (no LRU cache) + const result3 = loader.findResourceJSON('first-thing'); + expect(result3).toEqual({ id: 'first-thing', version: '1.2.3' }); + expect(packageCacheMock.getResourceAtPath).toHaveBeenCalledTimes(3); // called again + }); + + it('should return resource json from a virtual package', () => { + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'some.ig', version: '1.2.3' }); + vPackMock.registerResources.mockResolvedValue(); + vPackMock.getResourceByKey.calledWith('firstResource.json').mockReturnValue({ + id: '1', + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3' + }); + packageDBMock.getPackageStats + .calledWith('some.ig', '1.2.3') + .mockReturnValue({ name: 'some.ig', version: '1.2.3', resourceCount: 1 }); + loader.loadVirtualPackage(vPackMock); + packageDBMock.findResourceInfo.calledWith('firstResource').mockReturnValueOnce({ + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3', + resourcePath: 'virtual:some.ig#1.2.3:firstResource.json' + }); + const result = loader.findResourceJSON('firstResource'); + expect(result).toEqual({ + id: '1', + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3' + }); + }); + + it('should use the LRU cache for resource JSON on subsequent retrievals of the same resource from virtual package', () => { + loader.getPackageLoadStatus = jest.fn().mockReturnValueOnce(LoadStatus.NOT_LOADED); + const vPackMock = mock(); + vPackMock.getPackageJSON.mockReturnValue({ name: 'some.ig', version: '1.2.3' }); + vPackMock.registerResources.mockResolvedValue(); + vPackMock.getResourceByKey.calledWith('firstResource.json').mockReturnValue({ + id: '1', + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3' + }); + packageDBMock.getPackageStats + .calledWith('some.ig', '1.2.3') + .mockReturnValue({ name: 'some.ig', version: '1.2.3', resourceCount: 1 }); + loader.loadVirtualPackage(vPackMock); + packageDBMock.findResourceInfo.mockReturnValue({ + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3', + resourcePath: 'virtual:some.ig#1.2.3:firstResource.json' + }); + + // First call, should get it from the virtual package + expect(vPackMock.getResourceByKey).toHaveBeenCalledTimes(0); + const result = loader.findResourceJSON('firstResource'); + expect(result).toEqual({ + id: '1', + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3' + }); + expect(vPackMock.getResourceByKey).toHaveBeenCalledTimes(1); + + // Second call, should not call the virtual package again + const result2 = loader.findResourceJSON('firstResource'); + expect(result2).toEqual({ + id: '1', + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3' + }); + expect(vPackMock.getResourceByKey).toHaveBeenCalledTimes(1); // still 1 + + // Third call, using a different key, but resolves to same resource, should not call the virtual package again + const result3 = loader.findResourceJSON('1'); + expect(result3).toEqual({ + id: '1', + name: 'firstResource', + resourceType: 'StructureDefinition', + version: '1.2.3' + }); + expect(vPackMock.getResourceByKey).toHaveBeenCalledTimes(1); // still 1 + }); + it('should return undefined when the info does not contain a resourcePath', () => { packageDBMock.findResourceInfo.calledWith('first-thing').mockReturnValueOnce({ name: 'firstResource', @@ -1066,6 +1823,13 @@ describe('BasePackageLoader', () => { }); }); + describe('#exportDB', () => { + it('should export the package DB', () => { + loader.exportDB(); + expect(packageDBMock.exportDB).toHaveBeenCalled(); + }); + }); + describe('#clear', () => { it('should clear the package DB', () => { loader.clear(); diff --git a/test/loader/DefaultPackageLoader.test.ts b/test/loader/DefaultPackageLoader.test.ts index 29a1892..c75a54c 100644 --- a/test/loader/DefaultPackageLoader.test.ts +++ b/test/loader/DefaultPackageLoader.test.ts @@ -1,6 +1,6 @@ import { loggerSpy } from '../testhelpers'; import { jest } from '@jest/globals'; -import { defaultPackageLoader, defaultPackageLoaderWithLocalResources } from '../../src/loader'; +import { defaultPackageLoader } from '../../src/loader'; import { BasePackageLoader } from '../../src/loader/BasePackageLoader'; import { DiskBasedPackageCache } from '../../src/cache/DiskBasedPackageCache'; @@ -32,24 +32,8 @@ describe('DefaultPackageLoader', () => { const loader = await defaultPackageLoader({ log: loggerSpy.log }); expect(loader).toBeInstanceOf(BasePackageLoader); expect(DiskBasedPackageCache as jest.Mock).toHaveBeenCalledTimes(1); - expect(DiskBasedPackageCache as jest.Mock).toHaveBeenCalledWith(expect.any(String), [], { + expect(DiskBasedPackageCache as jest.Mock).toHaveBeenCalledWith(expect.any(String), { log: loggerSpy.log }); }); - - it('should create an instance of BasePackageLoader with specified local resource folders', async () => { - const loader = await defaultPackageLoaderWithLocalResources( - ['/some/folder', '/another/good/folder'], - { log: loggerSpy.log } - ); - expect(loader).toBeInstanceOf(BasePackageLoader); - expect(DiskBasedPackageCache as jest.Mock).toHaveBeenCalledTimes(1); - expect(DiskBasedPackageCache as jest.Mock).toHaveBeenCalledWith( - expect.any(String), - ['/some/folder', '/another/good/folder'], - { - log: loggerSpy.log - } - ); - }); }); diff --git a/test/registry/FHIRRegistryClient.test.ts b/test/registry/FHIRRegistryClient.test.ts index ab42a1a..60aff9a 100644 --- a/test/registry/FHIRRegistryClient.test.ts +++ b/test/registry/FHIRRegistryClient.test.ts @@ -1,4 +1,5 @@ import { FHIRRegistryClient } from '../../src/registry/FHIRRegistryClient'; +import * as registryUtils from '../../src/registry/utils'; import { loggerSpy } from '../testhelpers'; import axios from 'axios'; import { Readable } from 'stream'; @@ -18,7 +19,7 @@ const TERM_PKG_RESPONSE = { version: '1.2.3-test', description: 'None.', dist: { - shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe74983', + shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe74984', tarball: 'https://packages.simplifier.net/hl7.terminology.r4/1.2.3-test' }, fhirVersion: 'R4', @@ -62,6 +63,55 @@ describe('FHIRRegistryClient', () => { }); }); + describe('#resolveVersion', () => { + let resolveVersionSpy: jest.SpyInstance; + + // There's no need to re-test all the functionality in utils.resolveVersion, + // so just be sure the data is being passed correctly to the util function + // and the response is being passed back as expected. + beforeEach(() => { + resolveVersionSpy = jest.spyOn(registryUtils, 'resolveVersion'); + loggerSpy.reset(); + }); + afterEach(() => { + resolveVersionSpy.mockRestore(); + }); + + it('should resolve the latest using the util function and the client endpoint', async () => { + resolveVersionSpy.mockResolvedValue('2.4.6'); + const latest = await client.resolveVersion('my.favorite.package', 'latest'); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + expect(resolveVersionSpy).toHaveBeenCalledWith( + 'https://packages.fhir.org', + 'my.favorite.package', + 'latest' + ); + expect(latest).toEqual('2.4.6'); + }); + + it('should bubble up LatestVersionUnavailableError when the util function throws it', async () => { + resolveVersionSpy.mockRejectedValueOnce( + new LatestVersionUnavailableError('my.unavailable.package') + ); + const latest = client.resolveVersion('my.unavailable.package', 'latest'); + await expect(latest).rejects.toThrow(LatestVersionUnavailableError); + await expect(latest).rejects.toThrow( + /Latest version of package my.unavailable.package could not be determined from the package registry/ + ); + }); + + it('should bubble up IncorrectWildcardVersionFormatError when the util function throws it', async () => { + resolveVersionSpy.mockRejectedValueOnce( + new IncorrectWildcardVersionFormatError('my.other.package', '1.x') + ); + const latest = client.resolveVersion('my.other.package', '1.x'); + await expect(latest).rejects.toThrow(IncorrectWildcardVersionFormatError); + await expect(latest).rejects.toThrow( + /Incorrect version format for package my.other.package: 1.x. Wildcard should only be used to specify patch versions./ + ); + }); + }); + describe('#download', () => { describe('#downloadSpecificVersion', () => { beforeEach(() => { @@ -109,19 +159,21 @@ describe('FHIRRegistryClient', () => { axiosSpy.mockRestore(); }); + // this it('should throw error if no name given for download method', async () => { - const latest = client.download('', '5.5.5'); + const result = client.download('', '5.5.5'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow('Not found'); + // It should have successfully logged the attempt to download before rejecting expect(loggerSpy.getLastMessage('info')).toBe( 'Attempting to download #5.5.5 from https://packages.fhir.org//5.5.5' ); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow('Not found'); }); it('should throw error if no version given for download method', async () => { - const latest = client.download('hl7.terminology.r4', ''); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow('Not found'); + const result = client.download('hl7.terminology.r4', ''); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow('Not found'); expect(loggerSpy.getLastMessage('info')).toBe( 'Attempting to download hl7.terminology.r4# from https://packages.fhir.org/hl7.terminology.r4/' ); @@ -129,59 +181,60 @@ describe('FHIRRegistryClient', () => { it('should throw error if no endpoint given for download method', async () => { const emptyClient = new FHIRRegistryClient('', { log: loggerSpy.log }); - const latest = emptyClient.download('hl7.terminology.r4', '1.2.3-test'); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow('Not found'); + const result = emptyClient.download('hl7.terminology.r4', '1.2.3-test'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow('Not found'); expect(loggerSpy.getLastMessage('info')).toBe( 'Attempting to download hl7.terminology.r4#1.2.3-test from /hl7.terminology.r4/1.2.3-test' ); }); it('should get the data of the package when 200 response', async () => { - const latest = await client.download('hl7.terminology.r4', '1.2.3-test'); + const result = await client.download('hl7.terminology.r4', '1.2.3-test'); expect(loggerSpy.getLastMessage('info')).toBe( 'Attempting to download hl7.terminology.r4#1.2.3-test from https://packages.fhir.org/hl7.terminology.r4/1.2.3-test' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); - expect(latest).toBeInstanceOf(Readable); - expect(latest.read()).toBe('1.2.3-test-data'); + expect(result).toBeInstanceOf(Readable); + expect(result.read()).toBe('1.2.3-test-data'); }); it('should throw error when trying to get the version of a package on the packages server but status is not 200', async () => { - const latest = client.download('hl7.terminology.r4', '1.1.2'); + const result = client.download('hl7.terminology.r4', '1.1.2'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow( + 'Failed to download hl7.terminology.r4#1.1.2 from https://packages.fhir.org/hl7.terminology.r4/1.1.2' + ); + // It should have successfully logged the attempt to download before rejecting expect(loggerSpy.getLastMessage('info')).toBe( 'Attempting to download hl7.terminology.r4#1.1.2 from https://packages.fhir.org/hl7.terminology.r4/1.1.2' ); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow( - 'Failed to download hl7.terminology.r4#1.1.2 from https://packages.fhir.org/hl7.terminology.r4/1.1.2' - ); }); it('should throw error when trying to get the version of a package on the server but returns status with incorrect type', async () => { - const latest = client.download('hl7.terminology.r4', '5.5.5'); - await expect(latest).rejects.toThrow( + const result = client.download('hl7.terminology.r4', '5.5.5'); + await expect(result).rejects.toThrow( 'Failed to download hl7.terminology.r4#5.5.5 from https://packages.fhir.org/hl7.terminology.r4/5.5.5' ); }); it('should throw error when trying to get the version of a package on the server but returns no status', async () => { - const latest = client.download('hl7.terminology.r4', '5.5.6-test'); - await expect(latest).rejects.toThrow( + const result = client.download('hl7.terminology.r4', '5.5.6-test'); + await expect(result).rejects.toThrow( 'Failed to download hl7.terminology.r4#5.5.6-test from https://packages.fhir.org/hl7.terminology.r4/5.5.6-test' ); }); it('should throw error when trying to get the version of a package on the server but returns 200 status and data of incorrect type', async () => { - const latest = client.download('hl7.terminology.r4', '2.2.2'); - await expect(latest).rejects.toThrow( + const result = client.download('hl7.terminology.r4', '2.2.2'); + await expect(result).rejects.toThrow( 'Failed to download hl7.terminology.r4#2.2.2 from https://packages.fhir.org/hl7.terminology.r4/2.2.2' ); }); it('should throw error when trying to get the version of a package on the server but returns 200 status and no data field', async () => { - const latest = client.download('hl7.terminology.r4', '3.3.3'); - await expect(latest).rejects.toThrow( + const result = client.download('hl7.terminology.r4', '3.3.3'); + await expect(result).rejects.toThrow( 'Failed to download hl7.terminology.r4#3.3.3 from https://packages.fhir.org/hl7.terminology.r4/3.3.3' ); }); @@ -234,7 +287,7 @@ describe('FHIRRegistryClient', () => { const latest = client.download('hl7.bogus.package', 'latest'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest version of package hl7.bogus.package could not be determined from the FHIR package registry/ + /Latest version of package hl7.bogus.package could not be determined from the package registry/ ); }); @@ -242,7 +295,7 @@ describe('FHIRRegistryClient', () => { const latest = client.download('hl7.no.latest', 'latest'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest version of package hl7.no.latest could not be determined from the FHIR package registry/ + /Latest version of package hl7.no.latest could not be determined from the package registry/ ); }); }); @@ -345,7 +398,7 @@ describe('FHIRRegistryClient', () => { const latest = client.download('hl7.bogus.package', '1.0.x'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest patch version of package hl7.bogus.package could not be determined from the FHIR package registry/ + /Latest patch version of package hl7.bogus.package could not be determined from the package registry/ ); }); @@ -353,7 +406,7 @@ describe('FHIRRegistryClient', () => { const latest = client.download('hl7.no.versions', '1.0.x'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest patch version of package hl7.no.versions could not be determined from the FHIR package registry/ + /Latest patch version of package hl7.no.versions could not be determined from the package registry/ ); }); @@ -361,7 +414,7 @@ describe('FHIRRegistryClient', () => { const latest = client.download('hl7.no.good.patches', '1.0.x'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest patch version of package hl7.no.good.patches could not be determined from the FHIR package registry/ + /Latest patch version of package hl7.no.good.patches could not be determined from the package registry/ ); }); diff --git a/test/registry/NPMRegistryClient.test.ts b/test/registry/NPMRegistryClient.test.ts index c599955..0143c31 100644 --- a/test/registry/NPMRegistryClient.test.ts +++ b/test/registry/NPMRegistryClient.test.ts @@ -1,4 +1,5 @@ import { NPMRegistryClient } from '../../src/registry/NPMRegistryClient'; +import * as registryUtils from '../../src/registry/utils'; import { loggerSpy } from '../testhelpers'; import axios from 'axios'; import { Readable } from 'stream'; @@ -7,10 +8,11 @@ import { LatestVersionUnavailableError } from '../../src/errors'; -// Represents a typical package manifest response from packages.fhir.org +// Represents a typical (but abbreviated) package manifest response from an NPM registry const TERM_PKG_RESPONSE = { _id: 'hl7.terminology.r4', name: 'hl7.terminology.r4', + modified: '2022-05-16T22:27:54.741Z', 'dist-tags': { latest: '1.2.3-test' }, versions: { '1.2.3-test': { @@ -19,10 +21,8 @@ const TERM_PKG_RESPONSE = { description: 'None.', dist: { shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe74983', - tarball: 'https://packages.simplifier.net/hl7.terminology.r4/1.2.3-test' - }, - fhirVersion: 'R4', - url: 'https://packages.simplifier.net/hl7.terminology.r4/1.2.3-test' + tarball: 'https://my.npm.server.org/hl7.terminology.r4/1.2.3-test' + } }, '5.5.6-test': { name: 'hl7.terminology.r4', @@ -30,10 +30,8 @@ const TERM_PKG_RESPONSE = { description: 'None.', dist: { shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe74983', - tarball: 'https://packages.simplifier.net/hl7.terminology.r4/5.5.6-test' - }, - fhirVersion: 'R4', - url: 'https://packages.simplifier.net/hl7.terminology.r4/5.5.6-test' + tarball: 'https://my.npm.server.org/hl7.terminology.r4/5.5.6-test' + } }, 'tarbal-wrong-type-test': { name: 'hl7.terminology.r4', @@ -41,10 +39,8 @@ const TERM_PKG_RESPONSE = { description: 'None.', dist: { shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe74983', - tarball: ['https://packages.simplifier.net/hl7.terminology.r4/tarbal-wrong-type-test'] - }, - fhirVersion: 'R4', - url: 'https://packages.simplifier.net/hl7.terminology.r4/tarbal-wrong-type-test' + tarball: ['https://my.npm.server.org/hl7.terminology.r4/tarbal-wrong-type-test'] + } }, '1.1.2': { name: 'hl7.terminology.r4', @@ -52,10 +48,8 @@ const TERM_PKG_RESPONSE = { description: 'None.', dist: { shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe749822', - tarball: 'https://packages.simplifier.net/hl7.terminology.r4/1.1.2' - }, - fhirVersion: 'R4', - url: 'https://packages.simplifier.net/hl7.terminology.r4/1.1.2' + tarball: 'https://my.npm.server.org/hl7.terminology.r4/1.1.2' + } }, '1.1.1': { name: 'hl7.terminology.r4', @@ -63,10 +57,8 @@ const TERM_PKG_RESPONSE = { description: 'None.', dist: { shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe749821', - tarball: 'https://packages.simplifier.net/hl7.terminology.r4/1.1.1' - }, - fhirVersion: 'R4', - url: 'https://packages.simplifier.net/hl7.terminology.r4/1.1.1' + tarball: 'https://my.npm.server.org/hl7.terminology.r4/1.1.1' + } }, '2.2.2': { name: 'hl7.terminology.r4', @@ -74,10 +66,8 @@ const TERM_PKG_RESPONSE = { description: 'None.', dist: { shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe749821', - tarball: 'https://packages.simplifier.net/hl7.terminology.r4/2.2.2' - }, - fhirVersion: 'R4', - url: 'https://packages.simplifier.net/hl7.terminology.r4/2.2.2' + tarball: 'https://my.npm.server.org/hl7.terminology.r4/2.2.2' + } }, '3.3.3': { name: 'hl7.terminology.r4', @@ -85,10 +75,8 @@ const TERM_PKG_RESPONSE = { description: 'None.', dist: { shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe749821', - tarball: 'https://packages.simplifier.net/hl7.terminology.r4/3.3.3' - }, - fhirVersion: 'R4', - url: 'https://packages.simplifier.net/hl7.terminology.r4/3.3.3' + tarball: 'https://my.npm.server.org/hl7.terminology.r4/3.3.3' + } }, '1.2.4': { name: 'hl7.terminology.r4', @@ -96,23 +84,70 @@ const TERM_PKG_RESPONSE = { description: 'None.', dist: { shasum: '1a1467bce19aace45771e0a51ef2ad9c3fe749821' - }, - fhirVersion: 'R4', - url: 'https://packages.simplifier.net/hl7.terminology.r4/1.2.4' + } } } }; describe('NPMRegistryClient', () => { - const client = new NPMRegistryClient('https://packages.fhir.org', { log: loggerSpy.log }); + const client = new NPMRegistryClient('https://my.npm.server.org', { log: loggerSpy.log }); let axiosSpy: jest.SpyInstance; describe('#constructor', () => { it('should remove trailing slash from endpoint', async () => { - const clientWithSlash = new NPMRegistryClient('https://packages.fhir.org/', { + const clientWithSlash = new NPMRegistryClient('https://my.npm.server.org/', { log: loggerSpy.log }); - expect(clientWithSlash.endpoint).toBe('https://packages.fhir.org'); + expect(clientWithSlash.endpoint).toBe('https://my.npm.server.org'); + }); + }); + + describe('#resolveVersion', () => { + let resolveVersionSpy: jest.SpyInstance; + + // There's no need to re-test all the functionality in utils.resolveVersion, + // so just be sure the data is being passed correctly to the util function + // and the response is being passed back as expected. + beforeEach(() => { + resolveVersionSpy = jest.spyOn(registryUtils, 'resolveVersion'); + loggerSpy.reset(); + }); + afterEach(() => { + resolveVersionSpy.mockRestore(); + }); + + it('should resolve the latest using the util function and the client endpoint', async () => { + resolveVersionSpy.mockResolvedValue('2.4.6'); + const latest = await client.resolveVersion('my.favorite.package', 'latest'); + expect(loggerSpy.getAllMessages('error')).toHaveLength(0); + expect(resolveVersionSpy).toHaveBeenCalledWith( + 'https://my.npm.server.org', + 'my.favorite.package', + 'latest' + ); + expect(latest).toEqual('2.4.6'); + }); + + it('should bubble up LatestVersionUnavailableError when the util function throws it', async () => { + resolveVersionSpy.mockRejectedValueOnce( + new LatestVersionUnavailableError('my.unavailable.package') + ); + const latest = client.resolveVersion('my.unavailable.package', 'latest'); + await expect(latest).rejects.toThrow(LatestVersionUnavailableError); + await expect(latest).rejects.toThrow( + /Latest version of package my.unavailable.package could not be determined from the package registry/ + ); + }); + + it('should bubble up IncorrectWildcardVersionFormatError when the util function throws it', async () => { + resolveVersionSpy.mockRejectedValueOnce( + new IncorrectWildcardVersionFormatError('my.other.package', '1.x') + ); + const latest = client.resolveVersion('my.other.package', '1.x'); + await expect(latest).rejects.toThrow(IncorrectWildcardVersionFormatError); + await expect(latest).rejects.toThrow( + /Incorrect version format for package my.other.package: 1.x. Wildcard should only be used to specify patch versions./ + ); }); }); @@ -123,14 +158,14 @@ describe('NPMRegistryClient', () => { }); beforeAll(() => { axiosSpy = jest.spyOn(axios, 'get').mockImplementation((uri: string): any => { - if (uri === 'https://packages.fhir.org/hl7.terminology.r4') { + if (uri === 'https://my.npm.server.org/hl7.terminology.r4') { return { data: TERM_PKG_RESPONSE }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/1.1.2') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/1.1.2') { return { status: 404, data: Readable.from(['1.1.2-test-data']) }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/1.1.1') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/1.1.1') { return { status: 200, data: null @@ -147,18 +182,18 @@ describe('NPMRegistryClient', () => { it('should throw error when trying to get the version of a package on the packages server but status is not 200', async () => { // Note: don't know of a scenario where this would occur but testing for completeness. - const latest = client.download('hl7.terminology.r4', '1.1.2'); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow( - 'Failed to download hl7.terminology.r4#1.1.2 from https://packages.simplifier.net/hl7.terminology.r4/1.1.2' + const result = client.download('hl7.terminology.r4', '1.1.2'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow( + 'Failed to download hl7.terminology.r4#1.1.2 from https://my.npm.server.org/hl7.terminology.r4/1.1.2' ); }); it('should throw error when trying to get the version of a package on the packages server but returns no data', async () => { - const latest = client.download('hl7.terminology.r4', '1.1.1'); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow( - 'Failed to download hl7.terminology.r4#1.1.1 from https://packages.simplifier.net/hl7.terminology.r4/1.1.1' + const result = client.download('hl7.terminology.r4', '1.1.1'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow( + 'Failed to download hl7.terminology.r4#1.1.1 from https://my.npm.server.org/hl7.terminology.r4/1.1.1' ); }); }); @@ -170,27 +205,27 @@ describe('NPMRegistryClient', () => { }); beforeAll(() => { axiosSpy = jest.spyOn(axios, 'get').mockImplementation((uri: string): any => { - if (uri === 'https://packages.fhir.org/hl7.terminology.r4') { + if (uri === 'https://my.npm.server.org/hl7.terminology.r4') { return { data: TERM_PKG_RESPONSE }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/1.2.3-test') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/1.2.3-test') { return { status: 200, data: Readable.from(['1.2.3-test-data']) }; } else if ( uri === - 'https://packages.fhir.org/hl7.terminology.r4.no.manifest/-/hl7.terminology.r4.no.manifest-1.2.4.tgz' + 'https://my.npm.server.org/hl7.terminology.r4.no.manifest/-/hl7.terminology.r4.no.manifest-1.2.4.tgz' ) { return { status: 200, data: Readable.from(['1.2.4-no-manifest-test-data']) }; - } else if (uri === 'https://packages.fhir.org/hl7.terminology.r4.empty.manifest.data') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4.empty.manifest.data') { return { status: 200, data: '' }; - } else if (uri === 'https://packages.fhir.org/hl7.terminology.no-dist') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.no-dist') { return { status: 200, data: { @@ -204,13 +239,13 @@ describe('NPMRegistryClient', () => { }; } else if ( uri === - 'https://packages.fhir.org/hl7.terminology.r4.empty.manifest.data/-/hl7.terminology.r4.empty.manifest.data-1.2.4.tgz' + 'https://my.npm.server.org/hl7.terminology.r4.empty.manifest.data/-/hl7.terminology.r4.empty.manifest.data-1.2.4.tgz' ) { return { status: 200, data: Readable.from(['1.2.4-empty-manifest-test-data']) }; - } else if (uri === 'https://packages.fhir.org/hl7.terminology.r4.no.tarball') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4.no.tarball') { return { status: 200, data: { @@ -228,7 +263,7 @@ describe('NPMRegistryClient', () => { }; } else if ( uri === - 'https://packages.fhir.org/hl7.terminology.r4/-/hl7.terminology.r4-no-versions.tgz' + 'https://my.npm.server.org/hl7.terminology.r4/-/hl7.terminology.r4-no-versions.tgz' ) { return { status: 200, @@ -236,7 +271,7 @@ describe('NPMRegistryClient', () => { }; } else if ( uri === - 'https://packages.fhir.org/hl7.terminology.r4.no.tarball/-/hl7.terminology.r4.no.tarball-no-tarball-version.tgz' + 'https://my.npm.server.org/hl7.terminology.r4.no.tarball/-/hl7.terminology.r4.no.tarball-no-tarball-version.tgz' ) { return { status: 200, @@ -244,7 +279,7 @@ describe('NPMRegistryClient', () => { }; } else if ( uri === - 'https://packages.fhir.org/hl7.terminology.no-dist/-/hl7.terminology.no-dist-no-dist-version.tgz' + 'https://my.npm.server.org/hl7.terminology.no-dist/-/hl7.terminology.no-dist-no-dist-version.tgz' ) { return { status: 200, @@ -261,90 +296,90 @@ describe('NPMRegistryClient', () => { }); it('should get the package by creating a tgz file path when it has no manifest tarball', async () => { - const latest = await client.download('hl7.terminology.r4.no.manifest', '1.2.4'); + const result = await client.download('hl7.terminology.r4.no.manifest', '1.2.4'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.r4.no.manifest#1.2.4 from https://packages.fhir.org/hl7.terminology.r4.no.manifest/-/hl7.terminology.r4.no.manifest-1.2.4.tgz' + 'Attempting to download hl7.terminology.r4.no.manifest#1.2.4 from https://my.npm.server.org/hl7.terminology.r4.no.manifest/-/hl7.terminology.r4.no.manifest-1.2.4.tgz' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); - expect(latest).toBeInstanceOf(Readable); - expect(latest.read()).toBe('1.2.4-no-manifest-test-data'); + expect(result).toBeInstanceOf(Readable); + expect(result.read()).toBe('1.2.4-no-manifest-test-data'); }); it('should get the package using a created tgz file path when has manifest but data is empty', async () => { - const latest = await client.download('hl7.terminology.r4.empty.manifest.data', '1.2.4'); + const result = await client.download('hl7.terminology.r4.empty.manifest.data', '1.2.4'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.r4.empty.manifest.data#1.2.4 from https://packages.fhir.org/hl7.terminology.r4.empty.manifest.data/-/hl7.terminology.r4.empty.manifest.data-1.2.4.tgz' + 'Attempting to download hl7.terminology.r4.empty.manifest.data#1.2.4 from https://my.npm.server.org/hl7.terminology.r4.empty.manifest.data/-/hl7.terminology.r4.empty.manifest.data-1.2.4.tgz' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); - expect(latest).toBeInstanceOf(Readable); - expect(latest.read()).toBe('1.2.4-empty-manifest-test-data'); + expect(result).toBeInstanceOf(Readable); + expect(result.read()).toBe('1.2.4-empty-manifest-test-data'); }); it('should get the package using a created tgz file path when has manifest but not correct version', async () => { - const latest = await client.download('hl7.terminology.r4', 'no-versions'); + const result = await client.download('hl7.terminology.r4', 'no-versions'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.r4#no-versions from https://packages.fhir.org/hl7.terminology.r4/-/hl7.terminology.r4-no-versions.tgz' + 'Attempting to download hl7.terminology.r4#no-versions from https://my.npm.server.org/hl7.terminology.r4/-/hl7.terminology.r4-no-versions.tgz' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); - expect(latest).toBeInstanceOf(Readable); - expect(latest.read()).toBe('no-versions-test-data'); + expect(result).toBeInstanceOf(Readable); + expect(result.read()).toBe('no-versions-test-data'); }); it('should get the package using a created tgz file path when has manifest but not dist', async () => { - const latest = await client.download('hl7.terminology.no-dist', 'no-dist-version'); + const result = await client.download('hl7.terminology.no-dist', 'no-dist-version'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.no-dist#no-dist-version from https://packages.fhir.org/hl7.terminology.no-dist/-/hl7.terminology.no-dist-no-dist-version.tgz' + 'Attempting to download hl7.terminology.no-dist#no-dist-version from https://my.npm.server.org/hl7.terminology.no-dist/-/hl7.terminology.no-dist-no-dist-version.tgz' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); - expect(latest).toBeInstanceOf(Readable); - expect(latest.read()).toBe('no-dist-test-data'); + expect(result).toBeInstanceOf(Readable); + expect(result.read()).toBe('no-dist-test-data'); }); it('should get the package using a created tgz file path when has manifest but not tarball in it', async () => { - const latest = await client.download( + const result = await client.download( 'hl7.terminology.r4.no.tarball', 'no-tarball-version' ); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.r4.no.tarball#no-tarball-version from https://packages.fhir.org/hl7.terminology.r4.no.tarball/-/hl7.terminology.r4.no.tarball-no-tarball-version.tgz' + 'Attempting to download hl7.terminology.r4.no.tarball#no-tarball-version from https://my.npm.server.org/hl7.terminology.r4.no.tarball/-/hl7.terminology.r4.no.tarball-no-tarball-version.tgz' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); - expect(latest).toBeInstanceOf(Readable); - expect(latest.read()).toBe('no-tarball-test-data'); + expect(result).toBeInstanceOf(Readable); + expect(result.read()).toBe('no-tarball-test-data'); }); it('should get the package using a created tgz file path when has manifest with tarball in it but tarball is incorrect type', async () => { - const latest = client.download('hl7.terminology.r4', 'tarbal-wrong-type-test'); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow('Not found'); + const result = client.download('hl7.terminology.r4', 'tarbal-wrong-type-test'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow('Not found'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.r4#tarbal-wrong-type-test from https://packages.simplifier.net/hl7.terminology.r4/tarbal-wrong-type-test' + 'Attempting to download hl7.terminology.r4#tarbal-wrong-type-test from https://my.npm.server.org/hl7.terminology.r4/tarbal-wrong-type-test' ); }); it('should throw error if no name given for download method', async () => { - const latest = client.download('', '5.5.5'); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow('Not found'); + const result = client.download('', '5.5.5'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow('Not found'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download #5.5.5 from https://packages.fhir.org//-/-5.5.5.tgz' + 'Attempting to download #5.5.5 from https://my.npm.server.org//-/-5.5.5.tgz' ); }); it('should throw error if no version given for download method', async () => { - const latest = client.download('hl7.terminology.r4', ''); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow('Not found'); + const result = client.download('hl7.terminology.r4', ''); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow('Not found'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.r4# from https://packages.fhir.org/hl7.terminology.r4/-/hl7.terminology.r4-.tgz' + 'Attempting to download hl7.terminology.r4# from https://my.npm.server.org/hl7.terminology.r4/-/hl7.terminology.r4-.tgz' ); }); it('should throw error if no endpoint given for download method', async () => { const emptyClient = new NPMRegistryClient('', { log: loggerSpy.log }); - const latest = emptyClient.download('hl7.terminology.r4', '1.2.3-test'); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow('Not found'); + const result = emptyClient.download('hl7.terminology.r4', '1.2.3-test'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow('Not found'); expect(loggerSpy.getLastMessage('info')).toBe( 'Attempting to download hl7.terminology.r4#1.2.3-test from /hl7.terminology.r4/-/hl7.terminology.r4-1.2.3-test.tgz' ); @@ -357,30 +392,30 @@ describe('NPMRegistryClient', () => { }); beforeAll(() => { axiosSpy = jest.spyOn(axios, 'get').mockImplementation((uri: string): any => { - if (uri === 'https://packages.fhir.org/hl7.terminology.r4') { + if (uri === 'https://my.npm.server.org/hl7.terminology.r4') { return { data: TERM_PKG_RESPONSE }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/1.2.3-test') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/1.2.3-test') { return { status: 200, data: Readable.from(['1.2.3-test-data']) }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/2.2.2') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/2.2.2') { return { status: 200, data: '' }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/3.3.3') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/3.3.3') { return { status: 200 }; } else if ( - uri === 'https://packages.fhir.org/hl7.terminology.r4/-/hl7.terminology.r4-5.5.5.tgz' + uri === 'https://my.npm.server.org/hl7.terminology.r4/-/hl7.terminology.r4-5.5.5.tgz' ) { return { status: 'wrong-type', data: Readable.from(['1.2.4-no-manifest-test-data']) }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/5.5.6-test') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/5.5.6-test') { return { data: Readable.from(['5.5.6-test-data']) }; @@ -395,40 +430,40 @@ describe('NPMRegistryClient', () => { }); it('should get the data of the package when 200 response', async () => { - const latest = await client.download('hl7.terminology.r4', '1.2.3-test'); + const result = await client.download('hl7.terminology.r4', '1.2.3-test'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.r4#1.2.3-test from https://packages.simplifier.net/hl7.terminology.r4/1.2.3-test' + 'Attempting to download hl7.terminology.r4#1.2.3-test from https://my.npm.server.org/hl7.terminology.r4/1.2.3-test' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); - expect(latest).toBeInstanceOf(Readable); - expect(latest.read()).toBe('1.2.3-test-data'); + expect(result).toBeInstanceOf(Readable); + expect(result.read()).toBe('1.2.3-test-data'); }); it('should throw error when trying to get the version of a package on the server but returns 200 status and data of incorrect type', async () => { - const latest = client.download('hl7.terminology.r4', '2.2.2'); - await expect(latest).rejects.toThrow( - 'Failed to download hl7.terminology.r4#2.2.2 from https://packages.simplifier.net/hl7.terminology.r4/2.2.2' + const result = client.download('hl7.terminology.r4', '2.2.2'); + await expect(result).rejects.toThrow( + 'Failed to download hl7.terminology.r4#2.2.2 from https://my.npm.server.org/hl7.terminology.r4/2.2.2' ); }); it('should throw error when trying to get the version of a package on the server but returns 200 status and no data field', async () => { - const latest = client.download('hl7.terminology.r4', '3.3.3'); - await expect(latest).rejects.toThrow( - 'Failed to download hl7.terminology.r4#3.3.3 from https://packages.simplifier.net/hl7.terminology.r4/3.3.3' + const result = client.download('hl7.terminology.r4', '3.3.3'); + await expect(result).rejects.toThrow( + 'Failed to download hl7.terminology.r4#3.3.3 from https://my.npm.server.org/hl7.terminology.r4/3.3.3' ); }); it('should throw error when trying to get the version of a package on the server but returns status with incorrect type', async () => { - const latest = client.download('hl7.terminology.r4', '5.5.5'); - await expect(latest).rejects.toThrow( - 'Failed to download hl7.terminology.r4#5.5.5 from https://packages.fhir.org/hl7.terminology.r4/-/hl7.terminology.r4-5.5.5.tgz' + const result = client.download('hl7.terminology.r4', '5.5.5'); + await expect(result).rejects.toThrow( + 'Failed to download hl7.terminology.r4#5.5.5 from https://my.npm.server.org/hl7.terminology.r4/-/hl7.terminology.r4-5.5.5.tgz' ); }); it('should throw error when trying to get the version of a package on the server but returns no status', async () => { - const latest = client.download('hl7.terminology.r4', '5.5.6-test'); - await expect(latest).rejects.toThrow( - 'Failed to download hl7.terminology.r4#5.5.6-test from https://packages.simplifier.net/hl7.terminology.r4/5.5.6-test' + const result = client.download('hl7.terminology.r4', '5.5.6-test'); + await expect(result).rejects.toThrow( + 'Failed to download hl7.terminology.r4#5.5.6-test from https://my.npm.server.org/hl7.terminology.r4/5.5.6-test' ); }); }); @@ -440,14 +475,14 @@ describe('NPMRegistryClient', () => { }); beforeAll(() => { axiosSpy = jest.spyOn(axios, 'get').mockImplementation((uri: string): any => { - if (uri === 'https://packages.fhir.org/hl7.terminology.r4') { + if (uri === 'https://my.npm.server.org/hl7.terminology.r4') { return { data: TERM_PKG_RESPONSE }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/1.2.3-test') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/1.2.3-test') { return { status: 200, data: Readable.from(['1.2.3-test-data']) }; - } else if (uri === 'https://packages.fhir.org/hl7.no.latest') { + } else if (uri === 'https://my.npm.server.org/hl7.no.latest') { return { data: { name: 'hl7.no.latest', @@ -470,7 +505,7 @@ describe('NPMRegistryClient', () => { it('should get the latest version of a package on the packages server', async () => { const latest = await client.download('hl7.terminology.r4', 'latest'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.r4#1.2.3-test from https://packages.simplifier.net/hl7.terminology.r4/1.2.3-test' + 'Attempting to download hl7.terminology.r4#1.2.3-test from https://my.npm.server.org/hl7.terminology.r4/1.2.3-test' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); expect(latest).toBeInstanceOf(Readable); @@ -481,7 +516,7 @@ describe('NPMRegistryClient', () => { const latest = client.download('hl7.bogus.package', 'latest'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest version of package hl7.bogus.package could not be determined from the FHIR package registry/ + /Latest version of package hl7.bogus.package could not be determined from the package registry/ ); }); @@ -489,7 +524,7 @@ describe('NPMRegistryClient', () => { const latest = client.download('hl7.no.latest', 'latest'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest version of package hl7.no.latest could not be determined from the FHIR package registry/ + /Latest version of package hl7.no.latest could not be determined from the package registry/ ); }); }); @@ -500,9 +535,9 @@ describe('NPMRegistryClient', () => { }); beforeAll(() => { axiosSpy = jest.spyOn(axios, 'get').mockImplementation((uri: string): any => { - if (uri === 'https://packages.fhir.org/hl7.terminology.r4') { + if (uri === 'https://my.npm.server.org/hl7.terminology.r4') { return { data: TERM_PKG_RESPONSE }; - } else if (uri === 'https://packages.fhir.org/hl7.no.versions') { + } else if (uri === 'https://my.npm.server.org/hl7.no.versions') { return { data: { name: 'hl7.no.versions', @@ -512,7 +547,7 @@ describe('NPMRegistryClient', () => { } } }; - } else if (uri === 'https://packages.fhir.org/hl7.no.good.patches') { + } else if (uri === 'https://my.npm.server.org/hl7.no.good.patches') { return { data: { name: 'hl7.no.good.patches', @@ -528,7 +563,7 @@ describe('NPMRegistryClient', () => { } } }; - } else if (uri === 'https://packages.fhir.org/hl7.patches.with.snapshots') { + } else if (uri === 'https://my.npm.server.org/hl7.patches.with.snapshots') { return { data: { name: 'hl7.patches.with.snapshots', @@ -537,32 +572,32 @@ describe('NPMRegistryClient', () => { name: 'hl7.patches.with.snapshots', version: '2.0.0', dist: { - tarball: 'https://packages.simplifier.net/hl7.terminology.r4/2.0.0' + tarball: 'https://my.npm.server.org/hl7.terminology.r4/2.0.0' } }, '2.0.1': { name: 'hl7.patches.with.snapshots', version: '2.0.1', dist: { - tarball: 'https://packages.simplifier.net/hl7.terminology.r4/2.0.1' + tarball: 'https://my.npm.server.org/hl7.terminology.r4/2.0.1' } }, '2.0.2-snapshot1': { name: 'hl7.patches.with.snapshots', version: '2.0.2-snapshot1', dist: { - tarball: 'https://packages.simplifier.net/hl7.terminology.r4/2.0.2-snapshot1' + tarball: 'https://my.npm.server.org/hl7.terminology.r4/2.0.2-snapshot1' } } } } }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/1.1.2') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/1.1.2') { return { status: 200, data: Readable.from(['1.1.2-test-data']) }; - } else if (uri === 'https://packages.simplifier.net/hl7.terminology.r4/2.0.1') { + } else if (uri === 'https://my.npm.server.org/hl7.terminology.r4/2.0.1') { return { status: 200, data: Readable.from(['2.0.1-test-data']) @@ -580,7 +615,7 @@ describe('NPMRegistryClient', () => { it('should get the latest patch version for a package on the packages server', async () => { const latest = await client.download('hl7.terminology.r4', '1.1.x'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.terminology.r4#1.1.2 from https://packages.simplifier.net/hl7.terminology.r4/1.1.2' + 'Attempting to download hl7.terminology.r4#1.1.2 from https://my.npm.server.org/hl7.terminology.r4/1.1.2' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); expect(latest).toBeInstanceOf(Readable); @@ -590,7 +625,7 @@ describe('NPMRegistryClient', () => { it('should get the latest patch version ignoring any versions with qualifiers after the version (-snapshot1)', async () => { const latest = await client.download('hl7.patches.with.snapshots', '2.0.x'); expect(loggerSpy.getLastMessage('info')).toBe( - 'Attempting to download hl7.patches.with.snapshots#2.0.1 from https://packages.simplifier.net/hl7.terminology.r4/2.0.1' + 'Attempting to download hl7.patches.with.snapshots#2.0.1 from https://my.npm.server.org/hl7.terminology.r4/2.0.1' ); expect(loggerSpy.getAllMessages('error')).toHaveLength(0); expect(latest).toBeInstanceOf(Readable); @@ -601,7 +636,7 @@ describe('NPMRegistryClient', () => { const latest = client.download('hl7.bogus.package', '1.0.x'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest patch version of package hl7.bogus.package could not be determined from the FHIR package registry/ + /Latest patch version of package hl7.bogus.package could not be determined from the package registry/ ); }); @@ -609,7 +644,7 @@ describe('NPMRegistryClient', () => { const latest = client.download('hl7.no.versions', '1.0.x'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest patch version of package hl7.no.versions could not be determined from the FHIR package registry/ + /Latest patch version of package hl7.no.versions could not be determined from the package registry/ ); }); @@ -617,7 +652,7 @@ describe('NPMRegistryClient', () => { const latest = client.download('hl7.no.good.patches', '1.0.x'); await expect(latest).rejects.toThrow(LatestVersionUnavailableError); await expect(latest).rejects.toThrow( - /Latest patch version of package hl7.no.good.patches could not be determined from the FHIR package registry/ + /Latest patch version of package hl7.no.good.patches could not be determined from the package registry/ ); }); diff --git a/test/registry/RedundantRegistryClient.test.ts b/test/registry/RedundantRegistryClient.test.ts index ca4656e..3255819 100644 --- a/test/registry/RedundantRegistryClient.test.ts +++ b/test/registry/RedundantRegistryClient.test.ts @@ -1,13 +1,19 @@ +import { LatestVersionUnavailableError } from '../../src/errors'; import { RedundantRegistryClient } from '../../src/registry/RedundantRegistryClient'; +import { RegistryClient } from '../../src/registry/RegistryClient'; import { loggerSpy } from '../testhelpers'; import { Readable } from 'stream'; -class MyMockClient { +class MyMockClient implements RegistryClient { public endpoint: string; constructor(endpoint: string) { this.endpoint = endpoint; } + async resolveVersion(name: string, version: string): Promise { + return version; + } + async download(name: string, version: string): Promise { // to mimic failure of download if (this.endpoint == 'failed.to.download') throw new Error('Failed to download'); @@ -19,6 +25,65 @@ class MyMockClient { } describe('RedundantRegistryClient', () => { + describe('#resolveVersion', () => { + beforeEach(() => { + loggerSpy.reset(); + }); + + it('should resolve using the first client specified', async () => { + const mockClient1 = new MyMockClient('https://first.packages.server.org'); + jest.spyOn(mockClient1, 'resolveVersion').mockResolvedValue('4.6.8'); + const mockClient2 = new MyMockClient('https://second.packages.server.org'); + const client = new RedundantRegistryClient([mockClient1, mockClient2], { + log: loggerSpy.log + }); + const latest = await client.resolveVersion('my.favorite.package', 'latest'); + expect(latest).toEqual('4.6.8'); + }); + + it('should resolve using the second client specified if the first cannot resolve the version', async () => { + const mockClient1 = new MyMockClient('https://first.packages.server.org'); + jest + .spyOn(mockClient1, 'resolveVersion') + .mockRejectedValue(new LatestVersionUnavailableError('my.favorite.package')); + const mockClient2 = new MyMockClient('https://second.packages.server.org'); + jest.spyOn(mockClient2, 'resolveVersion').mockResolvedValue('9.8.7'); + const client = new RedundantRegistryClient([mockClient1, mockClient2], { + log: loggerSpy.log + }); + const latest = await client.resolveVersion('my.favorite.package', 'latest'); + expect(latest).toEqual('9.8.7'); + }); + + it('should throw error when no package server provided', async () => { + const client = new RedundantRegistryClient([], { log: loggerSpy.log }); + const latest = client.resolveVersion('my.favorite.package', 'latest'); + await expect(latest).rejects.toThrow(Error); + await expect(latest).rejects.toThrow( + 'Failed to resolve version for my.favorite.package#latest' + ); + }); + + it('should throw error when all package servers provided fail', async () => { + const mockClient1 = new MyMockClient('https://first.packages.server.org'); + jest + .spyOn(mockClient1, 'resolveVersion') + .mockRejectedValue(new LatestVersionUnavailableError('my.favorite.package')); + const mockClient2 = new MyMockClient('https://second.packages.server.org'); + jest + .spyOn(mockClient2, 'resolveVersion') + .mockRejectedValue(new LatestVersionUnavailableError('my.favorite.package')); + const client = new RedundantRegistryClient([mockClient1, mockClient2], { + log: loggerSpy.log + }); + const latest = client.resolveVersion('my.favorite.package', 'latest'); + await expect(latest).rejects.toThrow(Error); + await expect(latest).rejects.toThrow( + 'Failed to resolve version for my.favorite.package#latest' + ); + }); + }); + describe('#download', () => { beforeEach(() => { loggerSpy.reset(); @@ -30,11 +95,11 @@ describe('RedundantRegistryClient', () => { const client = new RedundantRegistryClient([mockClient1, mockClient2], { log: loggerSpy.log }); - const latest = await client.download('hl7.terminology.r4', '1.1.2'); + const result = await client.download('hl7.terminology.r4', '1.1.2'); // should get first client specified that doesn't throw error - expect(latest).toBeInstanceOf(Readable); - expect(latest.read()).toBe( + expect(result).toBeInstanceOf(Readable); + expect(result.read()).toBe( 'MyMockDownload of hl7.terminology.r4#1.1.2 from https://packages.fhir.org/hl7.terminology.r4/1.1.2' ); }); @@ -45,20 +110,20 @@ describe('RedundantRegistryClient', () => { const client = new RedundantRegistryClient([mockClient1, mockClient2], { log: loggerSpy.log }); - const latest = await client.download('hl7.terminology.r4', '1.1.2'); + const result = await client.download('hl7.terminology.r4', '1.1.2'); // will get second client specified since first throw error and goes to next client - expect(latest).toBeInstanceOf(Readable); - expect(latest.read()).toBe( + expect(result).toBeInstanceOf(Readable); + expect(result.read()).toBe( 'MyMockDownload of hl7.terminology.r4#1.1.2 from https://packages-second-client.fhir.org/hl7.terminology.r4/1.1.2' ); }); it('should throw error when no package server provided', async () => { const client = new RedundantRegistryClient([], { log: loggerSpy.log }); - const latest = client.download('hl7.terminology.r4', '1.1.2'); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow('Failed to download hl7.terminology.r4#1.1.2'); + const result = client.download('hl7.terminology.r4', '1.1.2'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow('Failed to download hl7.terminology.r4#1.1.2'); }); it('should throw error when all package servers provided fail', async () => { @@ -67,9 +132,9 @@ describe('RedundantRegistryClient', () => { const client = new RedundantRegistryClient([mockClient1, mockClient2], { log: loggerSpy.log }); - const latest = client.download('hl7.terminology.r4', '1.1.2'); - await expect(latest).rejects.toThrow(Error); - await expect(latest).rejects.toThrow('Failed to download hl7.terminology.r4#1.1.2'); + const result = client.download('hl7.terminology.r4', '1.1.2'); + await expect(result).rejects.toThrow(Error); + await expect(result).rejects.toThrow('Failed to download hl7.terminology.r4#1.1.2'); }); }); }); diff --git a/test/registry/utils.test.ts b/test/registry/utils.test.ts new file mode 100644 index 0000000..36ba08e --- /dev/null +++ b/test/registry/utils.test.ts @@ -0,0 +1,160 @@ +import axios from 'axios'; +import { resolveVersion } from '../../src/registry/utils'; +import { + IncorrectWildcardVersionFormatError, + LatestVersionUnavailableError +} from '../../src/errors'; + +// Represents a minimal package manifest response w/ the necessary data for resolving versions. +// Note that a real response will have additional data in it. +const TERM_PKG_RESPONSE = { + _id: 'hl7.terminology.r4', + name: 'hl7.terminology.r4', + 'dist-tags': { latest: '1.2.3-test' }, + versions: { + '1.2.3-test': { + name: 'hl7.terminology.r4', + version: '1.2.3-test' + }, + '1.2.2': { + name: 'hl7.terminology.r4', + version: '1.2.2' + }, + '1.1.2': { + name: 'hl7.terminology.r4', + version: '1.1.2' + }, + '1.1.1': { + name: 'hl7.terminology.r4', + version: '1.1.1' + } + } +}; + +describe('#resolveVersion', () => { + let axiosSpy: jest.SpyInstance; + + beforeAll(() => { + axiosSpy = jest.spyOn(axios, 'get').mockImplementation((uri: string): any => { + if (uri === 'https://my.package.server.org/hl7.terminology.r4') { + return { data: TERM_PKG_RESPONSE }; + } else if (uri === 'https://my.package.server.org/hl7.no.latest') { + return { data: { name: 'hl7.no.latest' } }; + } else { + throw new Error('Not found'); + } + }); + }); + + afterAll(() => { + axiosSpy.mockRestore(); + }); + + describe('#latest', () => { + it('should resolve the latest version of a package on the packages server', async () => { + const latest = await resolveVersion( + 'https://my.package.server.org', + 'hl7.terminology.r4', + 'latest' + ); + expect(latest).toEqual('1.2.3-test'); + }); + + it('should throw LatestVersionUnavailableError when the request to get package information fails', async () => { + const latest = resolveVersion('https://my.package.server.org', 'hl7.bogus.package', 'latest'); + await expect(latest).rejects.toThrow(LatestVersionUnavailableError); + await expect(latest).rejects.toThrow( + /Latest version of package hl7.bogus.package could not be determined from the package registry/ + ); + }); + + it('should throw LatestVersionUnavailableError when the package exists, but has no latest tag', async () => { + const latest = resolveVersion('https://my.package.server.org', 'hl7.no.latest', 'latest'); + await expect(latest).rejects.toThrow(LatestVersionUnavailableError); + await expect(latest).rejects.toThrow( + /Latest version of package hl7.no.latest could not be determined from the package registry/ + ); + }); + }); + + describe('#wildcard patch', () => { + it('should resolve the latest patch version for a package on the packages server', async () => { + const latest = await resolveVersion( + 'https://my.package.server.org', + 'hl7.terminology.r4', + '1.1.x' + ); + expect(latest).toEqual('1.1.2'); + }); + + it('should resolve the latest patch version ignoring any versions with qualifiers after the version (-snapshot1)', async () => { + const latest = await resolveVersion( + 'https://my.package.server.org', + 'hl7.terminology.r4', + '1.2.x' + ); + expect(latest).toEqual('1.2.2'); + }); + + it('should throw LatestVersionUnavailableError when the request to get package information fails', async () => { + const latest = resolveVersion('https://my.package.server.org', 'hl7.bogus.package', '1.0.x'); + await expect(latest).rejects.toThrow(LatestVersionUnavailableError); + await expect(latest).rejects.toThrow( + /Latest patch version of package hl7.bogus.package could not be determined from the package registry/ + ); + }); + + it('should throw LatestVersionUnavailableError when the package exists, but has no versions listed', async () => { + const latest = resolveVersion('https://my.package.server.org', 'hl7.no.versions', '1.0.x'); + await expect(latest).rejects.toThrow(LatestVersionUnavailableError); + await expect(latest).rejects.toThrow( + /Latest patch version of package hl7.no.versions could not be determined from the package registry/ + ); + }); + + it('should throw LatestVersionUnavailableError when the package exists, but has no matching versions for the patch version supplied', async () => { + const latest = resolveVersion('https://my.package.server.org', 'hl7.terminology.r4', '1.3.x'); + await expect(latest).rejects.toThrow(LatestVersionUnavailableError); + await expect(latest).rejects.toThrow( + /Latest patch version of package hl7.terminology.r4 could not be determined from the package registry/ + ); + }); + + it('should throw IncorrectWildcardVersionFormatError when a wildcard is used for minor version', async () => { + const latest = resolveVersion('https://my.package.server.org', 'hl7.terminology.r4', '1.x'); + await expect(latest).rejects.toThrow(IncorrectWildcardVersionFormatError); + await expect(latest).rejects.toThrow( + /Incorrect version format for package hl7.terminology.r4: 1.x. Wildcard should only be used to specify patch versions./ + ); + }); + }); + + describe('#current or specific version', () => { + it('should resolve current to current since current does not use the latest algorithm', async () => { + const latest = await resolveVersion( + 'https://my.package.server.org', + 'hl7.terminology.r4', + 'current' + ); + expect(latest).toEqual('current'); + }); + + it('should resolve a specific version to itself', async () => { + const latest = await resolveVersion( + 'https://my.package.server.org', + 'hl7.terminology.r4', + '1.1.1' + ); + expect(latest).toEqual('1.1.1'); + }); + + it('should resolve a specific version to itself even if that version is not listed (resolveVersion is not a version validator)', async () => { + const latest = await resolveVersion( + 'https://my.package.server.org', + 'hl7.terminology.r4', + '9.9.9' + ); + expect(latest).toEqual('9.9.9'); + }); + }); +}); diff --git a/test/sort/byLoadOrder.test.ts b/test/sort/byLoadOrder.test.ts new file mode 100644 index 0000000..7bb6807 --- /dev/null +++ b/test/sort/byLoadOrder.test.ts @@ -0,0 +1,27 @@ +import { byLoadOrder } from '../../src/sort/byLoadOrder'; + +describe('#byLoadOrder', () => { + it('should be ascending by default', () => { + const result = byLoadOrder(); + expect(result).toEqual({ + sortBy: 'LoadOrder', + ascending: true + }); + }); + + it('should be ascending when argument is true', () => { + const result = byLoadOrder(true); + expect(result).toEqual({ + sortBy: 'LoadOrder', + ascending: true + }); + }); + + it('should be descending when argument is false', () => { + const result = byLoadOrder(false); + expect(result).toEqual({ + sortBy: 'LoadOrder', + ascending: false + }); + }); +}); diff --git a/test/sort/byType.test.ts b/test/sort/byType.test.ts new file mode 100644 index 0000000..423056c --- /dev/null +++ b/test/sort/byType.test.ts @@ -0,0 +1,19 @@ +import { byType } from '../../src/sort/byType'; + +describe('#byType', () => { + it('should be empty type list by default', () => { + const result = byType(); + expect(result).toEqual({ + sortBy: 'Type', + types: [] + }); + }); + + it('should preserve type order of args', () => { + const result = byType('ValueSet', 'StructureDefinition', 'CodeSystem'); + expect(result).toEqual({ + sortBy: 'Type', + types: ['ValueSet', 'StructureDefinition', 'CodeSystem'] + }); + }); +}); diff --git a/test/virtual/DiskBasedVirtualPackage.test.ts b/test/virtual/DiskBasedVirtualPackage.test.ts new file mode 100644 index 0000000..6fdb0a7 --- /dev/null +++ b/test/virtual/DiskBasedVirtualPackage.test.ts @@ -0,0 +1,326 @@ +import path from 'path'; +import { DiskBasedVirtualPackage } from '../../src/virtual/DiskBasedVirtualPackage'; +import { loggerSpy } from '../testhelpers'; + +describe('DiskBasedVirtualPackage', () => { + const local1Folder = path.resolve(__dirname, 'fixtures', 'local1'); + const local2Folder = path.resolve(__dirname, 'fixtures', 'local2'); + + function expectCallFn(registerFn: any, root: string) { + return ( + callNum: number, + fileName: string, + resourceType: string, + id: string, + allowNonResources = false + ) => { + expect(registerFn).toHaveBeenNthCalledWith( + callNum, + path.join(root, fileName), + expect.objectContaining({ id, resourceType }), + allowNonResources + ); + }; + } + + beforeEach(() => { + loggerSpy.reset(); + }); + + describe('#registerResources', () => { + it('should not register any resources when no paths were provided', async () => { + const registerFn = jest.fn(); + const vPack = new DiskBasedVirtualPackage({ name: 'vpack', version: '1.0.0' }); + await vPack.registerResources(registerFn); + expect(registerFn).toHaveBeenCalledTimes(0); + }); + + it('should register all potential resources in provided paths using default options', async () => { + const registerFn = jest.fn(); + const vPack = new DiskBasedVirtualPackage( + { name: 'vpack', version: '1.0.0' }, + [local1Folder, local2Folder], + { log: loggerSpy.log } + ); + await vPack.registerResources(registerFn); + + expect(registerFn).toHaveBeenCalledTimes(11); + const expectL1 = expectCallFn(registerFn, local1Folder); + const expectL2 = expectCallFn(registerFn, local2Folder); + expectL1(1, 'CodeSystem-a-to-d.json', 'CodeSystem', 'a-to-d'); + expectL1(2, 'CodeSystem-x-to-z.xml', 'CodeSystem', 'x-to-z'); + expectL1(3, 'StructureDefinition-family-member.json', 'StructureDefinition', 'family-member'); + expectL1( + 4, + 'StructureDefinition-human-being-logical-model.json', + 'StructureDefinition', + 'human-being-logical-model' + ); + expectL1(5, 'StructureDefinition-true-false.xml', 'StructureDefinition', 'true-false'); + expectL1( + 6, + 'StructureDefinition-valued-observation.json', + 'StructureDefinition', + 'valued-observation' + ); + expectL1(7, 'ValueSet-beginning-and-end.json', 'ValueSet', 'beginning-and-end'); + expectL2( + 8, + 'Binary-LogicalModelExample.json', + 'CustomLogicalModel', + 'example-json-logical-model' + ); + expectL2(9, 'Observation-A1Example.xml', 'Observation', 'A1Example'); + expectL2(10, 'Observation-B2Example.json', 'Observation', 'B2Example'); + expectL2(11, 'Patient-JamesPondExample.json', 'Patient', 'JamesPondExample'); + + expect( + loggerSpy + .getAllMessages('debug') + .some(m => /^Skipped spreadsheet XML file: .*resources-spreadsheet\.xml$/.test(m)) + ).toBeTruthy(); + expect( + loggerSpy + .getAllMessages('debug') + .some(m => + /^Skipped spreadsheet XML file: .*sneaky-spread-like-bread-sheet\.xml$/.test(m) + ) + ).toBeTruthy(); + expect( + loggerSpy + .getAllMessages('debug') + .some(m => /^Skipped non-JSON \/ non-XML file: .*not-a-resource\.txt$/.test(m)) + ).toBeTruthy(); + expect(loggerSpy.getAllLogs('info')).toHaveLength(2); + expect(loggerSpy.getFirstMessage('info')).toMatch( + /Found 2 spreadsheet\(s\) in directory: .*local1\./ + ); + expect(loggerSpy.getLastMessage('info')).toMatch( + /Found 1 non-JSON \/ non-XML file\(s\) in directory: .*local2\./ + ); + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(0); + }); + + it('should register all resources for direct paths to files', async () => { + const registerFn = jest.fn(); + const vPack = new DiskBasedVirtualPackage( + { name: 'vpack', version: '1.0.0' }, + [ + path.join(local1Folder, 'StructureDefinition-valued-observation.json'), + path.join(local2Folder, 'Observation-A1Example.xml') + ], + { log: loggerSpy.log } + ); + await vPack.registerResources(registerFn); + + expect(registerFn).toHaveBeenCalledTimes(2); + const expectL1 = expectCallFn(registerFn, local1Folder); + const expectL2 = expectCallFn(registerFn, local2Folder); + expectL1( + 1, + 'StructureDefinition-valued-observation.json', + 'StructureDefinition', + 'valued-observation' + ); + expectL2(2, 'Observation-A1Example.xml', 'Observation', 'A1Example'); + + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(0); + }); + + it('should register potential resources allowing non-resources when allowNonResources option is true', async () => { + const registerFn = jest.fn(); + const vPack = new DiskBasedVirtualPackage( + { name: 'vpack', version: '1.0.0' }, + [local2Folder], + { allowNonResources: true, log: loggerSpy.log } + ); + await vPack.registerResources(registerFn); + + expect(registerFn).toHaveBeenCalledTimes(4); + const expectL2 = expectCallFn(registerFn, local2Folder); + expectL2( + 1, + 'Binary-LogicalModelExample.json', + 'CustomLogicalModel', + 'example-json-logical-model', + true + ); + expectL2(2, 'Observation-A1Example.xml', 'Observation', 'A1Example', true); + expectL2(3, 'Observation-B2Example.json', 'Observation', 'B2Example', true); + expectL2(4, 'Patient-JamesPondExample.json', 'Patient', 'JamesPondExample', true); + + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(0); + }); + + it('should register nested potential resources when recursive option is true', async () => { + const registerFn = jest.fn(); + const vPack = new DiskBasedVirtualPackage( + { name: 'vpack', version: '1.0.0' }, + [local2Folder], + { recursive: true, log: loggerSpy.log } + ); + await vPack.registerResources(registerFn); + + expect(registerFn).toHaveBeenCalledTimes(6); + const expectL2 = expectCallFn(registerFn, local2Folder); + expectL2( + 1, + 'Binary-LogicalModelExample.json', + 'CustomLogicalModel', + 'example-json-logical-model' + ); + expectL2(2, 'Observation-A1Example.xml', 'Observation', 'A1Example'); + expectL2(3, 'Observation-B2Example.json', 'Observation', 'B2Example'); + expectL2(4, 'Patient-JamesPondExample.json', 'Patient', 'JamesPondExample'); + expectL2( + 5, + path.join('nested', 'Patient-NestedJamesPondExample.json'), + 'Patient', + 'NestedJamesPondExample' + ); + expectL2( + 6, + path.join('nested', 'doublyNested', 'Observation-DoublyNestedB2Example.json'), + 'Observation', + 'DoublyNestedB2Example' + ); + + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(0); + }); + + it('should gracefully handle errors thrown from the register function', async () => { + const registerFn = jest.fn().mockImplementation((file: string) => { + if (file.endsWith('Observation-B2Example.json')) { + throw new Error('Problem with B2Example'); + } + }); + const vPack = new DiskBasedVirtualPackage( + { name: 'vpack', version: '1.0.0' }, + [local2Folder], + { log: loggerSpy.log } + ); + await vPack.registerResources(registerFn); + + expect(registerFn).toHaveBeenCalledTimes(4); + const expectL2 = expectCallFn(registerFn, local2Folder); + expectL2( + 1, + 'Binary-LogicalModelExample.json', + 'CustomLogicalModel', + 'example-json-logical-model' + ); + expectL2(2, 'Observation-A1Example.xml', 'Observation', 'A1Example'); + expectL2(3, 'Observation-B2Example.json', 'Observation', 'B2Example'); + expectL2(4, 'Patient-JamesPondExample.json', 'Patient', 'JamesPondExample'); + + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(1); + expect(loggerSpy.getLastMessage('error')).toMatch( + /Failed to register resource at path: .*Observation-B2Example\.json/ + ); + }); + }); + + describe('#getPackageJSON', () => { + it('should get the package JSON from the virtual package', () => { + const vPack = new DiskBasedVirtualPackage( + { name: 'vpack', version: '1.0.0', otherProp: 'otherValue' }, + [local1Folder] + ); + expect(vPack.getPackageJSON()).toEqual({ + name: 'vpack', + version: '1.0.0', + otherProp: 'otherValue' + }); + }); + }); + + describe('#getResourceByKey', () => { + let vPack: DiskBasedVirtualPackage; + + beforeEach(() => { + const registerFn = jest.fn(); + vPack = new DiskBasedVirtualPackage( + { name: 'vpack', version: '1.0.0' }, + [local1Folder, local2Folder], + { log: loggerSpy.log } + ); + vPack.registerResources(registerFn); + }); + + it('should return a valid JSON resource', () => { + // DiskBasedVirtualPackage uses the filepath as the key + const totalPath = path.resolve(local1Folder, 'StructureDefinition-valued-observation.json'); + const resource = vPack.getResourceByKey(totalPath); + expect(resource).toBeDefined(); + expect(resource).toMatchObject({ + id: 'valued-observation', + resourceType: 'StructureDefinition', + type: 'Observation' + }); + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(0); + }); + + it('should return a resource with an xml path where xml was converted to a resource', () => { + const totalPath = path.resolve(local1Folder, 'StructureDefinition-true-false.xml'); + const resource = vPack.getResourceByKey(totalPath); + expect(resource).toBeDefined(); + expect(resource).toMatchObject({ + id: 'true-false', + resourceType: 'StructureDefinition', + type: 'Extension' + }); + expect(resource.xml).toBeUndefined(); + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(0); + }); + + it('should throw error when path points to a xml file that cannot be converted to JSON', () => { + // An unconvertible XML file won't be registered, so it can't be retrieved + const totalPath = path.resolve(local2Folder, 'Binary-LogicalModelExample.xml'); + expect(() => { + vPack.getResourceByKey(totalPath); + }).toThrow(/Unregistered resource key: .*Binary-LogicalModelExample\.xml/); + }); + + it('should throw error when path points to file that is not xml or json', () => { + const totalPath = path.resolve(local2Folder, 'not-a-resource.txt'); + expect(() => { + vPack.getResourceByKey(totalPath); + }).toThrow(/Unregistered resource key: .*not-a-resource\.txt/); + }); + + it('should throw error when path points to a folder', () => { + const totalPath = path.resolve(local1Folder); + expect(() => { + vPack.getResourceByKey(totalPath); + }).toThrow(/Unregistered resource key: .*local1/); + }); + + it('should throw error when path points to a xml file that does not exist', () => { + const totalPath = path.resolve(local1Folder, 'example-file-that-doesnt-exist.xml'); + expect(() => { + vPack.getResourceByKey(totalPath); + }).toThrow(/Unregistered resource key: .*example-file-that-doesnt-exist\.xml/); + }); + + it('should throw error when path points to a json file that does not exist', () => { + const totalPath = path.resolve(local1Folder, 'example-file-that-doesnt-exist.json'); + expect(() => { + vPack.getResourceByKey(totalPath); + }).toThrow(/Unregistered resource key: .*example-file-that-doesnt-exist\.json/); + }); + + it('should throw error when path points to an invalid file type that does not exist', () => { + const totalPath = path.resolve(local1Folder, 'example-file-that-doesnt-exist.txt'); + expect(() => { + vPack.getResourceByKey(totalPath); + }).toThrow(/Unregistered resource key: .*example-file-that-doesnt-exist\.txt/); + }); + }); +}); diff --git a/test/virtual/InMemoryVirtualPackage.test.ts b/test/virtual/InMemoryVirtualPackage.test.ts new file mode 100644 index 0000000..4b4146d --- /dev/null +++ b/test/virtual/InMemoryVirtualPackage.test.ts @@ -0,0 +1,189 @@ +import { InMemoryVirtualPackage } from '../../src/virtual/InMemoryVirtualPackage'; +import { loggerSpy } from '../testhelpers'; + +describe('InMemoryVirtualPackage', () => { + let resourceMap: Map; + + beforeEach(() => { + loggerSpy.reset(); + resourceMap = new Map(); + resourceMap.set('Encounter-abc-123', { resourceType: 'Encounter', id: 'abc-123' }); + resourceMap.set('Observation-A', { resourceType: 'Observation', id: 'A' }); + resourceMap.set('Patient-1', { resourceType: 'Patient', id: '1' }); + }); + + describe('#registerResources', () => { + it('should not register any resources when empty map is provided', async () => { + const registerFn = jest.fn(); + const vPack = new InMemoryVirtualPackage( + { name: 'vpack', version: '1.0.0' }, + new Map() + ); + await vPack.registerResources(registerFn); + expect(registerFn).toHaveBeenCalledTimes(0); + }); + + it('should register all resources in provided map using default options', async () => { + const registerFn = jest.fn(); + const vPack = new InMemoryVirtualPackage({ name: 'vpack', version: '1.0.0' }, resourceMap, { + log: loggerSpy.log + }); + await vPack.registerResources(registerFn); + + expect(registerFn).toHaveBeenCalledTimes(3); + expect(registerFn).toHaveBeenNthCalledWith( + 1, + 'Encounter-abc-123', + { resourceType: 'Encounter', id: 'abc-123' }, + false + ); + expect(registerFn).toHaveBeenNthCalledWith( + 2, + 'Observation-A', + { resourceType: 'Observation', id: 'A' }, + false + ); + expect(registerFn).toHaveBeenNthCalledWith( + 3, + 'Patient-1', + { resourceType: 'Patient', id: '1' }, + false + ); + + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(0); + }); + + it('should register all resources in provided map allowing non-resources when allowNonResources option is true', async () => { + const registerFn = jest.fn(); + const vPack = new InMemoryVirtualPackage({ name: 'vpack', version: '1.0.0' }, resourceMap, { + allowNonResources: true, + log: loggerSpy.log + }); + await vPack.registerResources(registerFn); + + expect(registerFn).toHaveBeenCalledTimes(3); + expect(registerFn).toHaveBeenNthCalledWith( + 1, + 'Encounter-abc-123', + { resourceType: 'Encounter', id: 'abc-123' }, + true + ); + expect(registerFn).toHaveBeenNthCalledWith( + 2, + 'Observation-A', + { resourceType: 'Observation', id: 'A' }, + true + ); + expect(registerFn).toHaveBeenNthCalledWith( + 3, + 'Patient-1', + { resourceType: 'Patient', id: '1' }, + true + ); + + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(0); + }); + + it('should gracefully handle errors thrown from the register function', async () => { + const registerFn = jest.fn().mockImplementation((key: string) => { + if (key === 'Observation-A') { + throw new Error('Problem with Observation-A'); + } + }); + const vPack = new InMemoryVirtualPackage({ name: 'vpack', version: '1.0.0' }, resourceMap, { + log: loggerSpy.log + }); + await vPack.registerResources(registerFn); + + expect(registerFn).toHaveBeenCalledTimes(3); + expect(registerFn).toHaveBeenNthCalledWith( + 1, + 'Encounter-abc-123', + { resourceType: 'Encounter', id: 'abc-123' }, + false + ); + expect(registerFn).toHaveBeenNthCalledWith( + 2, + 'Observation-A', + { resourceType: 'Observation', id: 'A' }, + false + ); + expect(registerFn).toHaveBeenNthCalledWith( + 3, + 'Patient-1', + { resourceType: 'Patient', id: '1' }, + false + ); + + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(1); + expect(loggerSpy.getLastMessage('error')).toMatch( + 'Failed to register resource with key: Observation-A' + ); + }); + }); + + describe('#getPackageJSON', () => { + it('should get the package JSON from the virtual package', () => { + const vPack = new InMemoryVirtualPackage( + { name: 'vpack', version: '1.0.0', otherProp: 'otherValue' }, + resourceMap + ); + expect(vPack.getPackageJSON()).toEqual({ + name: 'vpack', + version: '1.0.0', + otherProp: 'otherValue' + }); + }); + }); + + describe('#getResourceByKey', () => { + let vPack: InMemoryVirtualPackage; + beforeEach(() => { + const registerFn = jest.fn().mockImplementation((key: string) => { + if (key === 'Observation-A') { + throw new Error('Problem with Observation-A'); + } + }); + vPack = new InMemoryVirtualPackage({ name: 'vpack', version: '1.0.0' }, resourceMap, { + log: loggerSpy.log + }); + vPack.registerResources(registerFn); + // reset the logger again since we expect the setup code to log an error during registration + loggerSpy.reset(); + }); + + it('should return a valid registered resource', () => { + const resource = vPack.getResourceByKey('Patient-1'); + expect(resource).toBeDefined(); + expect(resource).toMatchObject({ + id: '1', + resourceType: 'Patient' + }); + expect(loggerSpy.getAllLogs('warn')).toHaveLength(0); + expect(loggerSpy.getAllLogs('error')).toHaveLength(0); + }); + + it('should throw error when getting a key that was in the map but failed registration', () => { + expect(() => { + vPack.getResourceByKey('Observation-A'); + }).toThrow(/Unregistered resource key: Observation-A/); + }); + + it('should throw error when getting a key that was in the map but removed from the map after', () => { + // The map ought not be modified, but just in case someone does it + resourceMap.delete('Patient-1'); + expect(() => { + vPack.getResourceByKey('Patient-1'); + }).toThrow(/Could not find in-memory resource with key: Patient-1/); + }); + + it('should throw error when getting a key that was not in the resource map', () => { + expect(() => { + vPack.getResourceByKey('SomeResource-5'); + }).toThrow(/Unregistered resource key: SomeResource-5/); + }); + }); +}); diff --git a/test/cache/fixtures/local/local1/CodeSystem-a-to-d.json b/test/virtual/fixtures/local1/CodeSystem-a-to-d.json similarity index 100% rename from test/cache/fixtures/local/local1/CodeSystem-a-to-d.json rename to test/virtual/fixtures/local1/CodeSystem-a-to-d.json diff --git a/test/cache/fixtures/local/local1/CodeSystem-x-to-z.xml b/test/virtual/fixtures/local1/CodeSystem-x-to-z.xml similarity index 100% rename from test/cache/fixtures/local/local1/CodeSystem-x-to-z.xml rename to test/virtual/fixtures/local1/CodeSystem-x-to-z.xml diff --git a/test/cache/fixtures/local/local1/StructureDefinition-family-member.json b/test/virtual/fixtures/local1/StructureDefinition-family-member.json similarity index 100% rename from test/cache/fixtures/local/local1/StructureDefinition-family-member.json rename to test/virtual/fixtures/local1/StructureDefinition-family-member.json diff --git a/test/cache/fixtures/local/local1/StructureDefinition-human-being-logical-model.json b/test/virtual/fixtures/local1/StructureDefinition-human-being-logical-model.json similarity index 100% rename from test/cache/fixtures/local/local1/StructureDefinition-human-being-logical-model.json rename to test/virtual/fixtures/local1/StructureDefinition-human-being-logical-model.json diff --git a/test/cache/fixtures/local/local1/StructureDefinition-true-false.xml b/test/virtual/fixtures/local1/StructureDefinition-true-false.xml similarity index 100% rename from test/cache/fixtures/local/local1/StructureDefinition-true-false.xml rename to test/virtual/fixtures/local1/StructureDefinition-true-false.xml diff --git a/test/cache/fixtures/local/local1/StructureDefinition-valued-observation.json b/test/virtual/fixtures/local1/StructureDefinition-valued-observation.json similarity index 100% rename from test/cache/fixtures/local/local1/StructureDefinition-valued-observation.json rename to test/virtual/fixtures/local1/StructureDefinition-valued-observation.json diff --git a/test/cache/fixtures/local/local1/ValueSet-beginning-and-end.json b/test/virtual/fixtures/local1/ValueSet-beginning-and-end.json similarity index 100% rename from test/cache/fixtures/local/local1/ValueSet-beginning-and-end.json rename to test/virtual/fixtures/local1/ValueSet-beginning-and-end.json diff --git a/test/cache/fixtures/local/local1/resources-spreadsheet.xml b/test/virtual/fixtures/local1/resources-spreadsheet.xml similarity index 100% rename from test/cache/fixtures/local/local1/resources-spreadsheet.xml rename to test/virtual/fixtures/local1/resources-spreadsheet.xml diff --git a/test/cache/fixtures/local/local1/sneaky-spread-like-bread-sheet.xml b/test/virtual/fixtures/local1/sneaky-spread-like-bread-sheet.xml similarity index 100% rename from test/cache/fixtures/local/local1/sneaky-spread-like-bread-sheet.xml rename to test/virtual/fixtures/local1/sneaky-spread-like-bread-sheet.xml diff --git a/test/cache/fixtures/local/local2/Binary-LogicalModelExample.json b/test/virtual/fixtures/local2/Binary-LogicalModelExample.json similarity index 100% rename from test/cache/fixtures/local/local2/Binary-LogicalModelExample.json rename to test/virtual/fixtures/local2/Binary-LogicalModelExample.json diff --git a/test/cache/fixtures/local/local2/Binary-LogicalModelExample.xml b/test/virtual/fixtures/local2/Binary-LogicalModelExample.xml similarity index 100% rename from test/cache/fixtures/local/local2/Binary-LogicalModelExample.xml rename to test/virtual/fixtures/local2/Binary-LogicalModelExample.xml diff --git a/test/cache/fixtures/local/local2/Observation-A1Example.xml b/test/virtual/fixtures/local2/Observation-A1Example.xml similarity index 96% rename from test/cache/fixtures/local/local2/Observation-A1Example.xml rename to test/virtual/fixtures/local2/Observation-A1Example.xml index 716368a..909adcd 100644 --- a/test/cache/fixtures/local/local2/Observation-A1Example.xml +++ b/test/virtual/fixtures/local2/Observation-A1Example.xml @@ -17,7 +17,7 @@ - + \ No newline at end of file diff --git a/test/cache/fixtures/local/local2/Observation-B2Example.json b/test/virtual/fixtures/local2/Observation-B2Example.json similarity index 96% rename from test/cache/fixtures/local/local2/Observation-B2Example.json rename to test/virtual/fixtures/local2/Observation-B2Example.json index 627103c..8404569 100644 --- a/test/cache/fixtures/local/local2/Observation-B2Example.json +++ b/test/virtual/fixtures/local2/Observation-B2Example.json @@ -17,7 +17,7 @@ }] }, "subject" : { - "reference" : "JamesPond" + "reference" : "Patient/JamesPondExample" }, "valueInteger" : 2 } \ No newline at end of file diff --git a/test/cache/fixtures/local/local2/Patient-JamesPondExample.json b/test/virtual/fixtures/local2/Patient-JamesPondExample.json similarity index 100% rename from test/cache/fixtures/local/local2/Patient-JamesPondExample.json rename to test/virtual/fixtures/local2/Patient-JamesPondExample.json diff --git a/test/virtual/fixtures/local2/nested/Patient-NestedJamesPondExample.json b/test/virtual/fixtures/local2/nested/Patient-NestedJamesPondExample.json new file mode 100644 index 0000000..afce138 --- /dev/null +++ b/test/virtual/fixtures/local2/nested/Patient-NestedJamesPondExample.json @@ -0,0 +1,12 @@ +{ + "resourceType" : "Patient", + "id" : "NestedJamesPondExample", + "text" : { + "status" : "generated", + "div" : "

James Pond (no stated gender), DoB Unknown


" + }, + "name" : [{ + "family" : "Pond", + "given" : ["Nested", "James"] + }] +} \ No newline at end of file diff --git a/test/virtual/fixtures/local2/nested/doublyNested/Observation-DoublyNestedB2Example.json b/test/virtual/fixtures/local2/nested/doublyNested/Observation-DoublyNestedB2Example.json new file mode 100644 index 0000000..e16975d --- /dev/null +++ b/test/virtual/fixtures/local2/nested/doublyNested/Observation-DoublyNestedB2Example.json @@ -0,0 +1,23 @@ +{ + "resourceType" : "Observation", + "id" : "DoublyNestedB2Example", + "meta" : { + "profile" : ["http://example.org/fhir/locals/StructureDefinition/valued-observation"] + }, + "text" : { + "status" : "generated", + "div" : "

Generated Narrative: Observation

ResourceObservation "B2Example"

Profile: Valued Observation Profile

status: final

code: B (A to D Code System#A)

subject: JamesPond

value: 2

" + }, + "status" : "final", + "code" : { + "coding" : [{ + "system" : "http://example.org/fhir/locals/CodeSystem/a-to-d", + "code" : "C", + "display" : "D" + }] + }, + "subject" : { + "reference" : "Patient/NestedJamesPondExample" + }, + "valueInteger" : 2 +} \ No newline at end of file diff --git a/test/cache/fixtures/local/local2/not-a-resource.txt b/test/virtual/fixtures/local2/not-a-resource.txt similarity index 100% rename from test/cache/fixtures/local/local2/not-a-resource.txt rename to test/virtual/fixtures/local2/not-a-resource.txt