diff --git a/library/package-manager.js b/library/package-manager.js index c0a5681..00693d1 100644 --- a/library/package-manager.js +++ b/library/package-manager.js @@ -587,6 +587,50 @@ class PackageManager { } } + /** + * Fetch a package directly from a URL (e.g., a CI build .tgz) + * @param {string} url - URL to a package.tgz file + * @returns {Promise} Path to extracted package folder + */ + 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`); + } + + // 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); + } + + this.totalDownloaded = this.totalDownloaded + packageData.length; + return finalName; + } + /** * Extract package to cache folder * @param {string} packageId - Package identifier @@ -857,7 +901,9 @@ class PackageContentLoader { } fhirVersion() { - return this.package.fhirVersions[0]; + // Handle both modern 'fhirVersions' and older 'fhir-version-list' formats + const versions = this.package.fhirVersions || this.package['fhir-version-list']; + return versions ? versions[0] : undefined; } id() { diff --git a/tests/library/package-manager.test.js b/tests/library/package-manager.test.js index e4574d7..47d0571 100644 --- a/tests/library/package-manager.test.js +++ b/tests/library/package-manager.test.js @@ -305,6 +305,37 @@ const runTests = shouldRunSlowTests(); .toThrow('At least one package server must be provided'); }); }); + + describe('fetchUrl', () => { + test('should fetch a package from a direct URL and use name#version cache key', async () => { + const packageManager = new PackageManager(SERVERS, CACHE_FOLDER); + const url = 'https://packages2.fhir.org/packages/hl7.fhir.uv.tools/0.2.0'; + const result = await packageManager.fetchUrl(url); + expect(result).toBe('hl7.fhir.uv.tools#0.2.0'); + + // Verify the extracted package has content + const packageDir = path.join(CACHE_FOLDER, result); + const files = await fs.readdir(path.join(packageDir, 'package')); + expect(files).toContain('package.json'); + }, 30000); + + test('should throw on duplicate fetch of same package', async () => { + const packageManager = new PackageManager(SERVERS, CACHE_FOLDER); + const url = 'https://packages2.fhir.org/packages/hl7.fhir.uv.tools/0.2.0'; + await packageManager.fetchUrl(url); + await expect( + packageManager.fetchUrl(url) + ).rejects.toThrow(/already exists in cache/); + }, 30000); + + test('should throw on invalid URL', async () => { + const packageManager = new PackageManager(SERVERS, CACHE_FOLDER); + await expect( + packageManager.fetchUrl('https://invalid.example.com/nonexistent/package.tgz') + ).rejects.toThrow(); + }, 30000); + }); + }); describe('PackageContentLoader', () => { diff --git a/tx/README.md b/tx/README.md index aaf679a..c0f249e 100644 --- a/tx/README.md +++ b/tx/README.md @@ -298,6 +298,22 @@ You can specify a version using the `#` syntax: If no version is specified, the latest released version is fetched. +#### `url` - FHIR Packages from Direct URLs + +Loads a FHIR package directly from a tarball URL instead of the FHIR package registry. Useful for packages hosted on CI build servers, branches, or other locations. + +Use `url/cs` to load only CodeSystem resources from the package (same as `npm/cs`). + +```yaml +# Load a package from a CI build server +- url:https://example.com/my-package/package.tgz + +# Load a code-systems-only package from a URL +- url/cs:https://example.com/my-codesystems/package.tgz +``` + +The URL must point to a `.tgz` file in standard FHIR NPM package format. Downloaded packages are cached locally by URL. + ### Default Marker (`!`) When multiple versions of the same code system are loaded, append `!` to mark one as the default: diff --git a/tx/library.js b/tx/library.js index c4cba4f..c1cada9 100644 --- a/tx/library.js +++ b/tx/library.js @@ -255,6 +255,14 @@ class Library { await this.loadNpm(packageManager, details, isDefault, mode, true); break; + case 'url': + await this.loadUrl(packageManager, details, isDefault, mode, false); + break; + + case 'url/cs': + await this.loadUrl(packageManager, details, isDefault, mode, true); + break; + default: throw new Error(`Unknown source type: ${type}`); } @@ -459,6 +467,41 @@ class Library { this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs ? vs.valueSetMap.size : 0); } + async loadUrl(packageManager, url, isDefault, mode, csOnly) { + const packagePath = await packageManager.fetchUrl(url); + if (mode === "fetch" || mode === "cs") { + return; + } + const fullPackagePath = path.join(this.cacheFolder, packagePath); + const contentLoader = new PackageContentLoader(fullPackagePath); + await contentLoader.initialize(); + + this.contentSources.push(contentLoader.id()+"#"+contentLoader.version()); + + let cp = new ListCodeSystemProvider(); + const resources = await contentLoader.getResourcesByType("CodeSystem"); + let csc = 0; + for (const resource of resources) { + const cs = new CodeSystem(await contentLoader.loadFile(resource, contentLoader.fhirVersion())); + cs.sourcePackage = contentLoader.pid(); + cp.codeSystems.set(cs.url, cs); + cp.codeSystems.set(cs.vurl, cs); + csc++; + } + this.codeSystemProviders.push(cp); + let vs = null; + if (!csOnly) { + vs = new PackageValueSetProvider(contentLoader); + await vs.initialize(); + this.valueSetProviders.push(vs); + const cm = new PackageConceptMapProvider(contentLoader); + await cm.initialize(); + this.conceptMapProviders.push(cm); + } + + this.#logPackage(contentLoader.id(), contentLoader.version(), csc, vs ? vs.valueSetMap.size : 0); + } + /** * Gets a file from local folder or downloads it from URL * @param {string} fileName - Name of the file