-
Notifications
You must be signed in to change notification settings - Fork 40
Npm backed repo #884
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Npm backed repo #884
Changes from 5 commits
9baeb38
0c771a6
b3fa3c5
5293b2c
d050ef2
79dc771
2eeb5ad
6a767b7
6527188
661baa0
133bba6
cd55cdf
f839f54
ee681b2
1bcee10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) | ||
|
||
| .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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.