Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.opencds.cqf.fhir.cql.npm;

import jakarta.annotation.Nonnull;
import java.util.List;
import org.hl7.fhir.instance.model.api.IBaseResource;

/**
* A "repository" backed by fhir's NPM storage.
*/
public interface INpmRepository {

/**
* Resolve a resource by a class and url
* @param clazz - the class of the resource desired
* @param url - url of the resource desired (can be null)
* @return list of resources that match the condition
*/
<T extends IBaseResource> List<T> resolveByUrl(@Nonnull Class<T> clazz, String url);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package org.opencds.cqf.fhir.cql.npm;

import static java.util.Objects.requireNonNull;
import static org.apache.commons.lang3.StringUtils.isEmpty;

import ca.uhn.fhir.context.BaseRuntimeChildDefinition;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.parser.DataFormatException;
import ca.uhn.fhir.parser.IParser;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
import jakarta.annotation.Nonnull;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Paths;
import java.security.InvalidParameterException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.cqframework.fhir.npm.NpmProcessor;
import org.cqframework.fhir.utilities.IGContext;
import org.hl7.fhir.instance.model.api.IBase;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IPrimitiveType;
import org.hl7.fhir.r5.model.Enumerations;
import org.hl7.fhir.utilities.npm.NpmPackage;
import org.jetbrains.annotations.NotNull;
import org.opencds.cqf.fhir.cql.EvaluationSettings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class NpmBackedRepository implements INpmRepository {

/**
* Internal wrapper class to hold resource and provide access to
* canonical url field (if available).
*/
private class WrappedResource {

/**
* The resource being wrapped
*/
private final IBaseResource resource;

/**
* The canonical url (if available)
*/
private String canonical;

public WrappedResource(IBaseResource resource) {
this.resource = resource;

RuntimeResourceDefinition def = fhirContext.getResourceDefinition(resource);

Optional<BaseRuntimeChildDefinition> urlFieldOp = def.getChildren().stream()
.filter(f -> f.getElementName().equals("url"))
.findFirst();

if (urlFieldOp.isPresent()) {
BaseRuntimeChildDefinition urlField = urlFieldOp.get();
Optional<IBase> valueOp = urlField.getAccessor().getFirstValueOrNull(resource);
valueOp.ifPresent(v -> {
if (v instanceof IPrimitiveType<?> pt) {
canonical = pt.getValueAsString();
}
});
}
}

public IBaseResource getResource() {
return resource;
}

public boolean hasCanonicalUrl() {
return !isEmpty(canonical);
}

public String getCanonicalUrl() {
return canonical;
}
}

private static final Logger log = LoggerFactory.getLogger(NpmBackedRepository.class);

private final FhirContext fhirContext;
private final EvaluationSettings settings;
private NpmProcessor npmProcessor;

// cache these because the NpmPackage holds the unparsed files
// and parsing each time is work
private final Multimap<String, WrappedResource> resourceType2Resource = HashMultimap.create();

public NpmBackedRepository(FhirContext context, EvaluationSettings settings) {
this.fhirContext = context;
this.settings = settings;
}

public void loadIg(String folder, String pkgName) {
ensureInitialized();

try {
NpmPackage pkg = NpmPackage.fromFolder(Paths.get(folder, pkgName).toString(), false);
pkg.loadAllFiles();
npmProcessor.getPackageManager().getNpmList().add(pkg);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure this is the right way to use the package manager? It seems like the package manager ought to be the thing loading the package?

} catch (Exception ex) {
String msg = String.format("Could not load package %s in folder %s.", pkgName, folder);
log.error(msg, ex);
throw new RuntimeException(msg, ex);
}
}

private void ensureInitialized() {
if (npmProcessor != null) {
return;
}

npmProcessor = settings.getNpmProcessor();
if (npmProcessor == null) {
// for some reason we require a 'base' sourceig...
// and this base *must be* R5
org.hl7.fhir.r5.model.ImplementationGuide guide = new org.hl7.fhir.r5.model.ImplementationGuide();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason an NpmProcessor needs an IG is to establish the set of dependencies in use, which determines the scope of contents available to that processor. If we know that scope to start with, it should be part of the initialization. In other words, it sets the project scope, and we need to understand what we want the relationship to project scope to be for this implementation. Is the NpmBackedRepository supposed to be used from the perspective of a particular IG, or is it supposed to enable Npm access across all IGs loaded in a repository?

guide.addFhirVersion(getFhirVersionFromFhirVersion());
guide.setName("default"); // does this matter?
IGContext igContext = new IGContext();
igContext.setSourceIg(guide);
npmProcessor = new NpmProcessor(igContext);
settings.setNpmProcessor(npmProcessor);
}
}

private org.hl7.fhir.r5.model.Enumerations.FHIRVersion getFhirVersionFromFhirVersion() {
FhirVersionEnum fv = fhirContext.getVersion().getVersion();
switch (fv) {
case DSTU3 -> {
return Enumerations.FHIRVersion._3_0_0;
}
case R4 -> {
return org.hl7.fhir.r5.model.Enumerations.FHIRVersion._4_0_0;
}
case R5 -> {
return Enumerations.FHIRVersion._5_0_0;
}
default -> {
String msg = String.format("Unsupported FHIR version %s", fv.getFhirVersionString());
log.error(msg);
throw new InvalidParameterException(msg);
}
}
}

@Override
@SuppressWarnings("unchecked")
public <T extends IBaseResource> List<T> resolveByUrl(@Nonnull Class<T> clazz, String url) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There doesn't seem to be version management here, what are the expectations for versioned and versionless references?

requireNonNull(clazz, "clazz cannot be null");
String type = clazz.getSimpleName();
boolean hasUrl = !isEmpty(url);

if (!resourceType2Resource.containsKey(type)) {
populateCaches(clazz, type);
}

Collection<WrappedResource> resources = resourceType2Resource.get(type);

if (isListEmpty(resources)) {
return List.of();
}

if (hasUrl) {
return resources.stream()
.filter(r -> r.hasCanonicalUrl() && r.getCanonicalUrl().equals(url))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider a hash map here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

.map(wr -> (T) wr.getResource())
.collect(Collectors.toList());
} else {
return resources.stream().map(wr -> (T) wr.getResource()).toList();
}
}

private <T extends IBaseResource> void populateCaches(@NotNull Class<T> clazz, String type) {
var list = npmProcessor.getPackageManager().getNpmList();

for (var pkg : list) {
if (!pkg.getTypes().containsKey(type)) {
// if it doesn't have the provided resource type we can skip it
continue;
}

List<T> pkgResources = getResourcesFromPkg(pkg, clazz);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a lazy load on a cache miss. Packages may have literally 1000s of resources of any given type, and loading them all when we only need one is one of the most common scenarios here.

if (!isListEmpty(pkgResources)) {
for (T resource : pkgResources) {
resourceType2Resource.put(type, new WrappedResource(resource));
}
}
}
}

private <T extends IBaseResource> List<T> getResourcesFromPkg(NpmPackage pkg, Class<T> clazz) {
IParser parser = fhirContext.newJsonParser();

List<T> resources = new ArrayList<>();
String type = clazz.getSimpleName();
List<String> files = pkg.getTypes().get(type);
for (String file : files) {
try (InputStream is = pkg.loadResource(file)) {
String resourceStr = new String(is.readAllBytes());

T resource = parser.parseResource(clazz, resourceStr);
resources.add(resource);
} catch (IOException | DataFormatException ex) {
String msg = String.format("Could not parse resource from package %s", pkg.url());
log.error(msg, ex);
throw new RuntimeException(msg, ex);
}
}

return resources;
}

private boolean isListEmpty(Collection<?> collection) {
return collection == null || collection.isEmpty();
}
}
Loading
Loading