Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 37 additions & 32 deletions library/package-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,41 +594,46 @@ class PackageManager {
*/
async fetchUrl(url) {
console.log("Fetch Package from URL: " + url);
const client = new CIBuildClient();
const packageData = await client.fetchFromUrlSpecific(url);

// Extract to a temp location to read package.json for name and version
const tempKey = `_url_temp_${Date.now()}`;
const tempPath = await this.extractToCache(tempKey, 'url', packageData);
const tempFullPath = path.join(this.cacheFolder, tempPath);

// Read package name and version from the extracted package
const pkgJsonPath = path.join(tempFullPath, 'package', 'package.json');
const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'));
const packageId = pkgJson.name;
const version = pkgJson.version;

if (!packageId || !version) {
throw new Error(`Package at ${url} has no name or version in package.json`);
}
try {
const client = new CIBuildClient();
const packageData = await client.fetchFromUrlSpecific(url);

// Extract to a temp location to read package.json for name and version
const tempKey = `_url_temp_${Date.now()}`;
const tempPath = await this.extractToCache(tempKey, 'url', packageData);
const tempFullPath = path.join(this.cacheFolder, tempPath);

// Read package name and version from the extracted package
const pkgJsonPath = path.join(tempFullPath, 'package', 'package.json');
const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'));
const packageId = pkgJson.name;
const version = pkgJson.version;

if (!packageId || !version) {
throw new Error(`Package at ${url} has no name or version in package.json`);
}

// Use the same cache key format as npm packages
const finalName = `${packageId}#${version}`;
const finalPath = path.join(this.cacheFolder, finalName);
// Use the same cache key format as npm packages
const finalName = `${packageId}#${version}`;
const finalPath = path.join(this.cacheFolder, finalName);

// If it already exists, the same package is already loaded - that's a config error
try {
await fs.access(finalPath);
await fs.rm(tempFullPath, { recursive: true, force: true });
throw new Error(`Package ${finalName} already exists in cache. Check library config for duplicates (url: ${url})`);
} catch (e) {
if (e.message.includes('already exists')) throw e;
// Doesn't exist yet, rename temp to final
await fs.rename(tempFullPath, finalPath);
}
// If it already exists, the same package is already loaded - that's a config error
try {
await fs.access(finalPath);
await fs.rm(tempFullPath, { recursive: true, force: true });
throw new Error(`Package ${finalName} already exists in cache. Check library config for duplicates (url: ${url})`);
} catch (e) {
if (e.message.includes('already exists')) throw e;
// Doesn't exist yet, rename temp to final
await fs.rename(tempFullPath, finalPath);
}

this.totalDownloaded = this.totalDownloaded + packageData.length;
return finalName;
this.totalDownloaded = this.totalDownloaded + packageData.length;
return finalName;
} catch (error) {
console.error(`Error fetching package from URL ${url}: ${error.message}`);
throw error;
}
}

/**
Expand Down
98 changes: 98 additions & 0 deletions tests/tx/library-error-handling.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const { Library } = require('../../tx/library');
const path = require('path');
const fs = require('fs').promises;
const os = require('os');

describe('Library error handling', () => {
let tmpDir;
let yamlPath;

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lib-test-'));
yamlPath = path.join(tmpDir, 'library.yml');
});

afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});

function createLibrary(configFile) {
const log = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
};
const stats = { addStat: jest.fn() };
return { library: new Library(configFile, undefined, log, stats), log };
}

test('failed source reports which source failed and throws', async () => {
await fs.writeFile(yamlPath, [
'base:',
' url: https://storage.googleapis.com/tx-fhir-org',
'sources:',
' - internal:lang',
' - internal:INVALID_SOURCE',
' - internal:country',
].join('\n'));

const { library } = createLibrary(yamlPath);
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();

await expect(library.load()).rejects.toThrow();

// The error message should identify the failing source
const errorMessages = consoleSpy.mock.calls.map(c => c[0]);
expect(errorMessages.some(msg => msg.includes('INVALID_SOURCE'))).toBe(true);

consoleSpy.mockRestore();
}, 30000);

test('error message includes source name on fetch failure', async () => {
await fs.writeFile(yamlPath, [
'base:',
' url: https://storage.googleapis.com/tx-fhir-org',
'sources:',
' - npm:nonexistent.package.that.does.not.exist#99.99.99',
].join('\n'));

const { library } = createLibrary(yamlPath);
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();

await expect(library.load()).rejects.toThrow();

const errorMessages = consoleSpy.mock.calls.map(c => c[0]);
expect(errorMessages.some(msg => msg.includes('nonexistent.package'))).toBe(true);

consoleSpy.mockRestore();
}, 60000);

test('load succeeds with empty sources', async () => {
await fs.writeFile(yamlPath, [
'base:',
' url: https://storage.googleapis.com/tx-fhir-org',
'sources: []',
].join('\n'));

const { library, log } = createLibrary(yamlPath);
await library.load();

expect(log.error).not.toHaveBeenCalled();
}, 30000);

test('load succeeds with valid sources', async () => {
await fs.writeFile(yamlPath, [
'base:',
' url: https://storage.googleapis.com/tx-fhir-org',
'sources:',
' - internal:lang',
' - internal:country',
].join('\n'));

const { library } = createLibrary(yamlPath);
await library.load();

expect(library.codeSystemFactories.size).toBeGreaterThanOrEqual(2);
}, 30000);
});
21 changes: 18 additions & 3 deletions tx/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,12 @@ class Library {
this.log.info('Fetching Data from '+this.baseUrl);

for (const source of config.sources) {
await this.processSource(source, this.packageManager, "fetch");
try {
await this.processSource(source, this.packageManager, "fetch");
} catch (error) {
console.error(`Failed to fetch source '${source}': ${error.message}`);
throw error;
}
}

this.log.info("Downloaded "+((this.totalDownloaded + this.packageManager.totalDownloaded)/ 1024)+" kB");
Expand All @@ -167,13 +172,23 @@ class Library {
this.#logSystemHeader();

for (const source of config.sources) {
await this.processSource(source, this.packageManager, "cs");
try {
await this.processSource(source, this.packageManager, "cs");
} catch (error) {
console.error(`Failed to load code systems from '${source}': ${error.message}`);
throw error;
}
}
this.log.info('Loading Packages');
this.#logPackagesHeader();

for (const source of config.sources) {
await this.processSource(source, this.packageManager, "npm");
try {
await this.processSource(source, this.packageManager, "npm");
} catch (error) {
console.error(`Failed to load package '${source}': ${error.message}`);
throw error;
}
}

const endMemory = process.memoryUsage();
Expand Down
7 changes: 6 additions & 1 deletion tx/library/codesystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ class CodeSystem extends CanonicalResource {
// Convert to R5 format internally (modifies input for performance)
this.jsonObj = codeSystemToR5(this.jsonObj, fhirVersion);
if (!noMaps) {
this.validate();
try {
this.validate();
} catch (e) {
const id = this.jsonObj?.url ? `${this.jsonObj.url}|${this.jsonObj.version || ''}` : this.jsonObj?.name || 'unknown';
throw new Error(`${e.message} (in ${id})`);
}
this.buildMaps();
}
}
Expand Down
Loading