Skip to content
Merged
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
48 changes: 47 additions & 1 deletion library/package-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} 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
Expand Down Expand Up @@ -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() {
Expand Down
31 changes: 31 additions & 0 deletions tests/library/package-manager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
16 changes: 16 additions & 0 deletions tx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions tx/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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
Expand Down
Loading