From 5b410bb5badeb8a11d81a88e4d87076fb3254247 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 4 Jul 2025 15:15:23 -0400 Subject: [PATCH 01/65] Basic CRUD tests for repositories --- .../repository/InMemoryFhirRepository.java | 275 +++++++++++++++++ .../repository/matcher/IResourceMatcher.java | 279 ++++++++++++++++++ .../matcher/MultiVersionResourceMatcher.java | 101 +++++++ .../fhir/jpa/dao/HapiFhirRepositoryTest.java | 28 ++ .../repository/InMemoryRepositoryTest.java | 13 + .../jpa/repository/HapiFhirRepository.java | 4 +- .../uhn/fhir/repository/IRepositoryTest.java | 127 ++++++++ .../utilities/RepositoryTestDataBuilder.java | 34 +++ 8 files changed, 859 insertions(+), 2 deletions(-) create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/IResourceMatcher.java create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java create mode 100644 hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java create mode 100644 hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java create mode 100644 hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java create mode 100644 hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/test/utilities/RepositoryTestDataBuilder.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java new file mode 100644 index 000000000000..81a5ea350b31 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java @@ -0,0 +1,275 @@ +package ca.uhn.fhir.repository; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.repository.matcher.IResourceMatcher; +import ca.uhn.fhir.repository.matcher.MultiVersionResourceMatcher; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.server.exceptions.NotImplementedOperationException; +import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; +import ca.uhn.fhir.util.BundleBuilder; +import ca.uhn.fhir.util.BundleUtil; +import com.google.common.collect.Multimap; +import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.instance.model.api.IBaseConformance; +import org.hl7.fhir.instance.model.api.IBaseParameters; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent; +import org.opencds.cqf.fhir.utility.BundleHelper; +import org.opencds.cqf.fhir.utility.Canonicals; +import org.opencds.cqf.fhir.utility.Ids; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.opencds.cqf.fhir.utility.BundleHelper.newBundle; + + +/** + * An in-memory implementation of the FHIR repository interface. + * Based on org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository. + * This repository stores resources in memory + * and provides basic CRUD operations, search, and transaction support. + */ +public class InMemoryFhirRepository implements IRepository { + + private final Map> resourceMap; + private final FhirContext context; + private final IResourceMatcher resourceMatcher; + + public static InMemoryFhirRepository emptyRepository(FhirContext theFhirContext) { + return new InMemoryFhirRepository(theFhirContext, new HashMap<>()); + } + + public static InMemoryFhirRepository fromBundleContents(FhirContext theFhirContext, IBaseBundle theBundle) { + HashMap> contents = new HashMap<>(); + + var resources = BundleUtil.toListOfResources(theFhirContext, theBundle); + contents.putAll(resources.stream() + .collect(Collectors.groupingBy( + IBaseResource::fhirType, + Collectors.toMap(r -> r.getIdElement().toUnqualifiedVersionless(), Function.identity())))); + + return new InMemoryFhirRepository(theFhirContext, contents); + } + + InMemoryFhirRepository(FhirContext theContext, Map> theContents) { + context = theContext; + resourceMap = theContents; + resourceMatcher = new MultiVersionResourceMatcher(context); + } + + @Override + @SuppressWarnings("unchecked") + public T read( + Class resourceType, I id, Map headers) { + var resources = this.resourceMap.computeIfAbsent(resourceType.getSimpleName(), x -> new HashMap<>()); + + var resource = resources.get(id.toUnqualifiedVersionless()); + + if (resource == null) { + throw new ResourceNotFoundException(id); + } + + return (T) resource; + } + + @Override + public MethodOutcome create(T resource, Map headers) { + var resources = resourceMap.computeIfAbsent(resource.fhirType(), r -> new HashMap<>()); + + IIdType theId; + do { + theId = Ids.newRandomId(context, resource.fhirType()); + } while (resources.containsKey(theId)); + resource.setId(theId); + + resources.put(theId.toUnqualifiedVersionless(), resource); + + return new MethodOutcome(theId, true); + } + + @Override + public MethodOutcome patch( + I id, P patchParameters, Map headers) { + throw new NotImplementedOperationException("The PATCH operation is not currently supported"); + } + + @Override + public MethodOutcome update(T resource, Map headers) { + var resources = resourceMap.computeIfAbsent(resource.fhirType(), r -> new HashMap<>()); + var theId = resource.getIdElement().toUnqualifiedVersionless(); + var outcome = new MethodOutcome(theId, false); + if (!resources.containsKey(theId)) { + outcome.setCreated(true); + } + if (resource.fhirType().equals("SearchParameter")) { + this.resourceMatcher.addCustomParameter(BundleHelper.resourceToRuntimeSearchParam(resource)); + } + resources.put(theId, resource); + + return outcome; + } + + @Override + public MethodOutcome delete( + Class resourceType, I id, Map headers) { + var resources = resourceMap.computeIfAbsent(id.getResourceType(), r -> new HashMap<>()); + var keyId = id.toUnqualifiedVersionless(); + if (resources.containsKey(keyId)) { + resources.remove(keyId); + return new MethodOutcome(id, false) + .setResource(resources.get(keyId)); + } else { + throw new ResourceNotFoundException("Resource not found with id " + id); + } + } + + @Override + public B search(Class bundleType, Class resourceType, Multimap> searchParameters, Map headers) { + BundleBuilder builder = new BundleBuilder(this.context); + var resourceIdMap = resourceMap.computeIfAbsent(resourceType.getSimpleName(), r -> new HashMap<>()); + + if (searchParameters == null || searchParameters.isEmpty()) { + resourceIdMap.values().forEach(builder::addCollectionEntry); + builder.setType("searchset"); + return (B) builder.getBundle(); + } + + Collection candidates = resourceIdMap.values(); + // fixme +// if (searchParameters.containsKey("_id")) { +// // We are consuming the _id parameter in this if statement +// var idQueries = searchParameters.get("_id"); +// searchParameters.remove("_id"); +// +// // The _id param can be a list of ids +// var idResources = new ArrayList(idQueries.size()); +// for (var idQuery : idQueries) { +// var idToken = (TokenParam) idQuery; +// // Need to construct the equivalent "UnqualifiedVersionless" id that the map is +// // indexed by. If an id has a version it won't match. Need apples-to-apples Ids types +// var id = Ids.newId(context, resourceType.getSimpleName(), idToken.getValue()); +// var r = resourceIdMap.get(id); +// if (r != null) { +// idResources.add(r); +// } +// } +// +// candidates = idResources; +// } else { +// candidates = resourceIdMap.values(); +// } + + // Apply the rest of the filters +// for (var resource : candidates) { +// boolean include = true; +// for (var nextEntry : searchParameters.entrySet()) { +// var paramName = nextEntry.getKey(); +// if (!this.resourceMatcher.matches(paramName, nextEntry.getValue(), resource)) { +// include = false; +// break; +// } +// } +// +// if (include) { +// builder.addCollectionEntry(resource); +// } +// } + + builder.setType("searchset"); + return (B) builder.getBundle(); + } + + @Override + public B link(Class bundleType, String url, Map headers) { + throw new NotImplementedOperationException("Paging is not currently supported"); + } + + @Override + public C capabilities(Class resourceType, Map headers) { + throw new NotImplementedOperationException("The capabilities interaction is not currently supported"); + } + + @Override + public B transaction(B transaction, Map headers) { + var version = transaction.getStructureFhirVersionEnum(); + + @SuppressWarnings("unchecked") + var returnBundle = (B) newBundle(version); + BundleHelper.getEntry(transaction).forEach(e -> { + if (BundleHelper.isEntryRequestPut(version, e)) { + var outcome = this.update(BundleHelper.getEntryResource(version, e)); + var location = outcome.getId().getValue(); + BundleHelper.addEntry( + returnBundle, + BundleHelper.newEntryWithResponse( + version, BundleHelper.newResponseWithLocation(version, location))); + } else if (BundleHelper.isEntryRequestPost(version, e)) { + var outcome = this.create(BundleHelper.getEntryResource(version, e)); + var location = outcome.getId().getValue(); + BundleHelper.addEntry( + returnBundle, + BundleHelper.newEntryWithResponse( + version, BundleHelper.newResponseWithLocation(version, location))); + } else if (BundleHelper.isEntryRequestDelete(version, e)) { + if (BundleHelper.getEntryRequestId(version, e).isPresent()) { + var resourceType = Canonicals.getResourceType( + ((BundleEntryComponent) e).getRequest().getUrl()); + var resourceClass = + this.context.getResourceDefinition(resourceType).getImplementingClass(); + var res = this.delete( + resourceClass, + BundleHelper.getEntryRequestId(version, e).get().withResourceType(resourceType)); + BundleHelper.addEntry(returnBundle, BundleHelper.newEntryWithResource(res.getResource())); + } else { + throw new ResourceNotFoundException("Trying to delete an entry without id"); + } + + } else { + throw new NotImplementedOperationException("Transaction stub only supports PUT, POST or DELETE"); + } + }); + + return returnBundle; + } + + @Override + public R invoke(Class resourceType, String name, P parameters, Class returnType, Map headers) { + return null; + } + + @Override + public R invoke(I id, String name, P parameters, Class returnType, Map headers) { + return null; + } + + @Override + public B history( + P parameters, Class returnType, Map headers) { + throw new NotImplementedOperationException("The history interaction is not currently supported"); + } + + @Override + public B history( + Class resourceType, P parameters, Class returnType, Map headers) { + throw new NotImplementedOperationException("The history interaction is not currently supported"); + } + + @Override + public B history( + I id, P parameters, Class returnType, Map headers) { + throw new NotImplementedOperationException("The history interaction is not currently supported"); + } + + @Override + public FhirContext fhirContext() { + return this.context; + } + +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/IResourceMatcher.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/IResourceMatcher.java new file mode 100644 index 000000000000..d63a6166a193 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/IResourceMatcher.java @@ -0,0 +1,279 @@ +package ca.uhn.fhir.repository.matcher; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.fhirpath.IFhirPath; +import ca.uhn.fhir.fhirpath.IFhirPath.IParsedExpression; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.param.DateParam; +import ca.uhn.fhir.rest.param.DateRangeParam; +import ca.uhn.fhir.rest.param.ReferenceParam; +import ca.uhn.fhir.rest.param.StringParam; +import ca.uhn.fhir.rest.param.TokenParam; +import ca.uhn.fhir.rest.param.TokenParamModifier; +import ca.uhn.fhir.rest.param.UriParam; +import jakarta.annotation.Nonnull; +import org.apache.commons.lang3.NotImplementedException; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.instance.model.api.IBaseEnumeration; +import org.hl7.fhir.instance.model.api.IBaseReference; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.ICompositeType; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.instance.model.api.IPrimitiveType; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +public interface IResourceMatcher { + + record SPPathKey ( + @Nonnull String resourceType, + @Nonnull String resourcePath) { + public String path() { + return resourcePath; + } + }; + + public IFhirPath getEngine(); + + public FhirContext getContext(); + + public Map getPathCache(); + + public void addCustomParameter(RuntimeSearchParam searchParam); + + public Map getCustomParameters(); + + // The list here is an OR list. Meaning, if any element matches it's a match + default boolean matches(String name, List params, IBaseResource resource) { + boolean match = true; + + var context = getContext(); + var s = context.getResourceDefinition(resource).getSearchParam(name); + if (s == null) { + s = this.getCustomParameters().get(name); + } + if (s == null) { + throw new RuntimeException(String.format( + "The SearchParameter %s for Resource %s is not supported.", name, resource.fhirType())); + } + + var path = s.getPath(); + + // System search parameters... + if (path.isEmpty() && name.startsWith("_")) { + path = name.substring(1); + } + + List pathResult = null; + try { + var parsed = getPathCache().computeIfAbsent(new SPPathKey(resource.fhirType(), path), p -> { + try { + return getEngine().parse(p.path()); + } catch (Exception e) { + throw new RuntimeException( + String.format( + "Parsing SearchParameter %s for Resource %s resulted in an error.", + name, resource.fhirType()), + e); + } + }); + pathResult = getEngine().evaluate(resource, parsed, IBase.class); + } catch (Exception e) { + throw new RuntimeException( + String.format( + "Evaluating SearchParameter %s for Resource %s resulted in an error.", + name, resource.fhirType()), + e); + } + + if (pathResult == null || pathResult.isEmpty()) { + return false; + } + + for (IQueryParameterType param : params) { + for (var r : pathResult) { + if (param instanceof ReferenceParam) { + match = isMatchReference(param, r); + } else if (param instanceof DateParam date) { + match = isMatchDate(date, r); + } else if (param instanceof TokenParam token) { + // [parameter]=[code]: the value of [code] matches a Coding.code or Identifier.value irrespective of + // the value of the system property + // [parameter]=[system]|[code]: the value of [code] matches a Coding.code or Identifier.value, and + // the value of [system] matches the system property of the Identifier or Coding + // [parameter]=|[code]: the value of [code] matches a Coding.code or Identifier.value, and the + // Coding/Identifier has no system property + // [parameter]=[system]|: any element where the value of [system] matches the system property of the + // Identifier or Coding + match = applyModifiers(isMatchToken(token, r), token); + if (!match) { + var codes = getCodes(r); + match = isMatchCoding(token, r, codes); + } + } else if (param instanceof UriParam uri) { + match = isMatchUri(uri, r); + } else if (param instanceof StringParam string) { + match = isMatchString(string, r); + } else { + throw new NotImplementedException("Resource matching not implemented for search params of type " + + param.getClass().getSimpleName()); + } + + if (match) { + return true; + } + } + } + + return false; + } + + private static boolean applyModifiers(boolean input, TokenParam token) { + if (token.getModifier() != null && token.getModifier() != TokenParamModifier.NOT) { + throw new NotImplementedException("Only the NOT modifier is supported on tokens at this time"); + } + if (token.getModifier() == TokenParamModifier.NOT) { + return !input; + } + return input; + } + + default boolean isMatchReference(IQueryParameterType param, IBase pathResult) { + if (pathResult instanceof IBaseReference) { + return ((IBaseReference) pathResult) + .getReferenceElement() + .getValue() + .equals(((ReferenceParam) param).getValue()); + } else if (pathResult instanceof IPrimitiveType) { + return ((IPrimitiveType) pathResult).getValueAsString().equals(((ReferenceParam) param).getValue()); + } else if (pathResult instanceof Iterable) { + for (var element : (Iterable) pathResult) { + if (element instanceof IBaseReference + && ((IBaseReference) element) + .getReferenceElement() + .getValue() + .equals(((ReferenceParam) param).getValue())) { + return true; + } + if (element instanceof IPrimitiveType + && ((IPrimitiveType) element) + .getValueAsString() + .equals(((ReferenceParam) param).getValue())) { + return true; + } + } + } else { + throw new UnsupportedOperationException( + "Expected Reference element, found " + pathResult.getClass().getSimpleName()); + } + return false; + } + + default boolean isMatchDate(DateParam param, IBase pathResult) { + DateRangeParam dateRange; + // date, dateTime and instant are PrimitiveType + if (pathResult instanceof IPrimitiveType) { + var result = ((IPrimitiveType) pathResult).getValue(); + if (result instanceof Date) { + dateRange = new DateRangeParam((Date) result, (Date) result); + } else { + throw new UnsupportedOperationException( + "Expected date, found " + pathResult.getClass().getSimpleName()); + } + } else if (pathResult instanceof ICompositeType) { + dateRange = getDateRange((ICompositeType) pathResult); + } else { + throw new UnsupportedOperationException( + "Expected element of type date, dateTime, instant, Timing or Period, found " + + pathResult.getClass().getSimpleName()); + } + return matchesDateBounds(dateRange, new DateRangeParam(param)); + } + + default boolean isMatchToken(TokenParam param, IBase pathResult) { + if (param.getValue() == null) { + return true; + } + + if (pathResult instanceof IIdType) { + var id = (IIdType) pathResult; + return param.getValue().equals(id.getIdPart()); + } + + if (pathResult instanceof IBaseEnumeration) { + return param.getValue().equals(((IBaseEnumeration) pathResult).getValueAsString()); + } + + if (pathResult instanceof IPrimitiveType) { + return param.getValue().equals(((IPrimitiveType) pathResult).getValue()); + } + + return false; + } + + default boolean isMatchCoding(TokenParam param, IBase pathResult, List codes) { + if (codes == null || codes.isEmpty()) { + return false; + } + + if (param.getModifier() == TokenParamModifier.IN) { + throw new UnsupportedOperationException("In modifier is unsupported"); + } + + for (var c : codes) { + var matches = param.getValue().equals(c.getValue()) + && (param.getSystem() == null || param.getSystem().equals(c.getSystem())); + if (matches) { + return true; + } + } + + return false; + } + + default boolean isMatchUri(UriParam param, IBase pathResult) { + if (pathResult instanceof IPrimitiveType) { + return param.getValue().equals(((IPrimitiveType) pathResult).getValue()); + } + throw new UnsupportedOperationException("Expected element of type url or uri, found " + + pathResult.getClass().getSimpleName()); + } + + default boolean isMatchString(StringParam param, Object pathResult) { + if (pathResult instanceof IPrimitiveType) { + return param.getValue().equals(((IPrimitiveType) pathResult).getValue()); + } + throw new UnsupportedOperationException("Expected element of type string, found " + + pathResult.getClass().getSimpleName()); + } + + default boolean matchesDateBounds(DateRangeParam resourceRange, DateRangeParam paramRange) { + Date resourceLowerBound = resourceRange.getLowerBoundAsInstant(); + Date resourceUpperBound = resourceRange.getUpperBoundAsInstant(); + Date paramLowerBound = paramRange.getLowerBoundAsInstant(); + Date paramUpperBound = paramRange.getUpperBoundAsInstant(); + if (paramLowerBound == null && paramUpperBound == null) { + return false; + } else { + boolean result = true; + if (paramLowerBound != null) { + result &= resourceLowerBound.after(paramLowerBound) || resourceLowerBound.equals(paramLowerBound); + result &= resourceUpperBound.after(paramLowerBound) || resourceUpperBound.equals(paramLowerBound); + } + + if (paramUpperBound != null) { + result &= resourceLowerBound.before(paramUpperBound) || resourceLowerBound.equals(paramUpperBound); + result &= resourceUpperBound.before(paramUpperBound) || resourceUpperBound.equals(paramUpperBound); + } + + return result; + } + } + + DateRangeParam getDateRange(ICompositeType type); + + List getCodes(IBase codeElement); +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java new file mode 100644 index 000000000000..0dd8b8b21feb --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java @@ -0,0 +1,101 @@ +package ca.uhn.fhir.repository.matcher; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.RuntimeSearchParam; +import ca.uhn.fhir.fhirpath.IFhirPath; +import ca.uhn.fhir.rest.param.DateRangeParam; +import ca.uhn.fhir.rest.param.TokenParam; +import ca.uhn.fhir.util.FhirTerser; +import org.apache.commons.lang3.NotImplementedException; +import org.hl7.fhir.dstu3.model.Period; +import org.hl7.fhir.dstu3.model.Timing; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.instance.model.api.ICompositeType; +import org.hl7.fhir.instance.model.api.IPrimitiveType; + +import javax.annotation.Nonnull; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class MultiVersionResourceMatcher implements IResourceMatcher { + private static final Map ourFhirPathCache = new ConcurrentHashMap<>(); + private Map myPathCache = new HashMap<>(); + private Map myCustomSearchParams = new HashMap<>(); + + private final FhirContext myFhirContext; + + public MultiVersionResourceMatcher(FhirContext theFhirContext) { + myFhirContext = theFhirContext; + } + + @Override + public IFhirPath getEngine() { + return ourFhirPathCache.computeIfAbsent(myFhirContext, FhirContext::newFhirPath); + } + + @Override + public FhirContext getContext() { + return myFhirContext; + } + + @Override + public Map getPathCache() { + return myPathCache; + } + + @Override + public void addCustomParameter(RuntimeSearchParam searchParam) { + this.myCustomSearchParams.put(searchParam.getName(), searchParam); + } + + @Override + public Map getCustomParameters() { + return this.myCustomSearchParams; + } + + @Override + public DateRangeParam getDateRange(ICompositeType type) { + if (type instanceof Period) { + return new DateRangeParam(((Period) type).getStart(), ((Period) type).getEnd()); + } else if (type instanceof Timing) { + throw new NotImplementedException("Timing resolution has not yet been implemented"); + } else { + throw new UnsupportedOperationException("Expected element of type Period or Timing, found " + + type.getClass().getSimpleName()); + } + } + + @Override + public List getCodes(IBase theCodeElement) { + String elementTypeName = myFhirContext.getElementDefinition(theCodeElement.getClass()).getName(); + switch (elementTypeName) { + case "Coding" -> { + var terser = myFhirContext.newTerser(); + TokenParam e = codingToTokenParam(terser, theCodeElement); + return List.of(e); + } + case "code" -> { + String codeValue = ((IPrimitiveType) theCodeElement).getValueAsString(); + return List.of(new TokenParam(codeValue)); + } + case "CodeableConcept"-> { + var terser = myFhirContext.newTerser(); + return terser.getValues(theCodeElement, "codeing").stream().map(coding -> + codingToTokenParam(terser, coding)).toList(); + } + default -> + throw new UnsupportedOperationException("Expected element of type Coding, CodeType, or CodeableConcept, found " + elementTypeName); + } + } + + @Nonnull + private static TokenParam codingToTokenParam(FhirTerser theTerser, IBase theCodeElement) { + String system = theTerser.getSinglePrimitiveValueOrNull(theCodeElement, "system"); + String code = theTerser.getSinglePrimitiveValueOrNull(theCodeElement, "code"); + + return new TokenParam(system, code); + } + +} diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java new file mode 100644 index 000000000000..071ca1282f3d --- /dev/null +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java @@ -0,0 +1,28 @@ +package ca.uhn.fhir.jpa.dao; + +import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; +import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel; +import ca.uhn.fhir.jpa.repository.HapiFhirRepository; +import ca.uhn.fhir.jpa.test.BaseJpaR4Test; +import ca.uhn.fhir.repository.IRepository; +import ca.uhn.fhir.repository.IRepositoryTest; +import org.junit.jupiter.api.AfterEach; + +class HapiFhirRepositoryTest extends BaseJpaR4Test implements IRepositoryTest { + + @AfterEach + public void afterResetDao() { + myStorageSettings.setResourceServerIdStrategy(new JpaStorageSettings().getResourceServerIdStrategy()); + myStorageSettings.setResourceClientIdStrategy(new JpaStorageSettings().getResourceClientIdStrategy()); + myStorageSettings.setDefaultSearchParamsCanBeOverridden(new JpaStorageSettings().isDefaultSearchParamsCanBeOverridden()); + myStorageSettings.setNormalizedQuantitySearchLevel(NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_SEARCH_NOT_SUPPORTED); + myStorageSettings.setIndexOnContainedResources(new JpaStorageSettings().isIndexOnContainedResources()); + myStorageSettings.setIndexOnContainedResourcesRecursively(new JpaStorageSettings().isIndexOnContainedResourcesRecursively()); + } + + + @Override + public RepositoryTestSupport getRepositoryTestSupport() { + return new RepositoryTestSupport(new HapiFhirRepository(myDaoRegistry, mySrd, null)); + } +} diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java new file mode 100644 index 000000000000..e7f712284c5f --- /dev/null +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java @@ -0,0 +1,13 @@ +package ca.uhn.fhir.repository; + +import ca.uhn.fhir.context.FhirContext; + +public class InMemoryRepositoryTest implements IRepositoryTest { + FhirContext myFhirContext = FhirContext.forR4(); + InMemoryFhirRepository myRepository = InMemoryFhirRepository.emptyRepository(myFhirContext); + + @Override + public RepositoryTestSupport getRepositoryTestSupport() { + return new RepositoryTestSupport(myRepository); + } +} diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java index 38135edd106a..ade0b4e93983 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java @@ -140,7 +140,7 @@ public B search( SearchConverter converter = new SearchConverter(); converter.convertParameters(theSearchParameters, fhirContext()); details.setParameters(converter.myResultParameters); - details.setResourceName(myRestfulServer.getFhirContext().getResourceType(theResourceType)); + details.setResourceName(myDaoRegistry.getFhirContext().getResourceType(theResourceType)); IBundleProvider bundleProvider = myDaoRegistry.getResourceDao(theResourceType).search(converter.mySearchParameterMap, details); @@ -387,7 +387,7 @@ public B h @Override public FhirContext fhirContext() { - return myRestfulServer.getFhirContext(); + return myDaoRegistry.getFhirContext(); } protected R invoke(RequestDetails theDetails) { diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java new file mode 100644 index 000000000000..6cf1b80e1240 --- /dev/null +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -0,0 +1,127 @@ +package ca.uhn.fhir.repository; + + +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; +import ca.uhn.fhir.test.utilities.ITestDataBuilder; +import ca.uhn.fhir.test.utilities.RepositoryTestDataBuilder; +import ca.uhn.fhir.util.FhirTerser; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +/** Generic test of repository functionality */ +@SuppressWarnings({ + "java:S5960" // this is a test jar +}) +public interface IRepositoryTest { + Logger ourLog = LoggerFactory.getLogger(IRepositoryTest.class); + + @Test + default void testCreate_readById_contentsPersisted() { + // given + var b = getTestDataBuilder(); + var patient = b.buildPatient(b.withBirthdate("1970-02-14")); + IRepository repository = getRepository(); + + // when + MethodOutcome methodOutcome = repository.create(patient); + ourLog.info("Created resource with id: {} created:{}", methodOutcome.getId(), methodOutcome.getCreated()); + IBaseResource read = repository.read(patient.getClass(), methodOutcome.getId().toVersionless()); + + // then + assertThat(read) + .isNotNull() + .extracting(p-> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) + .as("resource body read matches persisted value") + .isEqualTo("1970-02-14"); + } + + @Test + default void testCreateClientAssignedId_readBySameId_findsResource() { + // given + var b = getTestDataBuilder(); + var patient = b.buildPatient(b.withId("pat123"), b.withBirthdate("1970-02-14")); + IRepository repository = getRepository(); + + // when + MethodOutcome methodOutcome = repository.update(patient); + IBaseResource read = repository.read(patient.getClass(), methodOutcome.getId()); + + // then + assertThat(read).isNotNull(); + assertThat(getTerser().getSinglePrimitiveValueOrNull(read, "birthDate")).isEqualTo("1970-02-14"); + assertThat(read.getIdElement().getIdPart()).isEqualTo("pat123"); + } + + @Test + default void testCreate_update_readById_verifyUpdatedContents() { + // given + String initialBirthdate = "1970-02-14"; + String updatedBirthdate = "1980-03-15"; + var b = getTestDataBuilder(); + var patient = b.buildPatient(b.withBirthdate(initialBirthdate)); + IRepository repository = getRepository(); + + // when - create + MethodOutcome createOutcome = repository.create(patient); + IIdType patientId = createOutcome.getId().toVersionless(); + + // update with different birthdate + var updatedPatient = b.buildPatient(b.withId(patientId), b.withBirthdate(updatedBirthdate)); + var updateOutcome = repository.update(updatedPatient); + assertThat(updateOutcome.getId().toVersionless()).isEqualTo(patientId); + assertThat(updateOutcome.getCreated()).isFalse(); + + // read + IBaseResource read = repository.read(patient.getClass(), patientId); + + // then + assertThat(read) + .isNotNull() + .extracting(p -> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) + .as("resource body read matches updated value") + .isEqualTo(updatedBirthdate); + } + + @Test + default void testCreate_delete_readById_throwsNotFound() { + // given a patient resource + var b = getTestDataBuilder(); + var patient = b.buildPatient(b.withBirthdate("1970-02-14")); + IRepository repository = getRepository(); + MethodOutcome createOutcome = repository.create(patient); + IIdType patientId = createOutcome.getId().toVersionless(); + + // when deleted + repository.delete(patient.getClass(), patientId); + + // then - read should throw ResourceNotFoundException + assertThrows(ResourceNotFoundException.class, () -> { + repository.read(patient.getClass(), patientId); + }); + } + + private FhirTerser getTerser() { + return getRepository().fhirContext().newTerser(); + } + + RepositoryTestSupport getRepositoryTestSupport(); + + default ITestDataBuilder getTestDataBuilder() { + return RepositoryTestDataBuilder.forRepository(getRepository()); + } + + private IRepository getRepository() { + return getRepositoryTestSupport().repository(); + } + + record RepositoryTestSupport(IRepository repository) { } +} diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/test/utilities/RepositoryTestDataBuilder.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/test/utilities/RepositoryTestDataBuilder.java new file mode 100644 index 000000000000..a42af77d52e8 --- /dev/null +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/test/utilities/RepositoryTestDataBuilder.java @@ -0,0 +1,34 @@ +package ca.uhn.fhir.test.utilities; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.repository.IRepository; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; + +public class RepositoryTestDataBuilder implements ITestDataBuilder { + + private final IRepository myRepository; + + RepositoryTestDataBuilder(IRepository theIgRepository) { + myRepository = theIgRepository; + } + + public static RepositoryTestDataBuilder forRepository(IRepository theRepository) { + return new RepositoryTestDataBuilder(theRepository); + } + + @Override + public IIdType doCreateResource(IBaseResource theResource) { + return myRepository.create(theResource).getId(); + } + + @Override + public IIdType doUpdateResource(IBaseResource theResource) { + return myRepository.update(theResource).getId(); + } + + @Override + public FhirContext getFhirContext() { + return myRepository.fhirContext(); + } +} From df5791418fc500477957d47546c855dcf03a66c1 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 4 Jul 2025 16:30:43 -0400 Subject: [PATCH 02/65] Basic CRUD tests for repositories --- .../ca/uhn/fhir/repository/IRepository.java | 4 +- .../repository/InMemoryFhirRepository.java | 433 +++++++++------- .../repository/matcher/IResourceMatcher.java | 487 +++++++++--------- .../matcher/MultiVersionResourceMatcher.java | 29 +- .../uhn/fhir/util/OperationOutcomeUtil.java | 34 ++ .../fhir/jpa/dao/BaseHapiFhirResourceDao.java | 3 +- .../fhir/jpa/dao/HapiFhirRepositoryTest.java | 1 - .../ca/uhn/fhir/jpa/dao/BaseStorageDao.java | 53 +- .../jpa/repository/HapiFhirRepository.java | 7 +- .../uhn/fhir/repository/IRepositoryTest.java | 90 +++- 10 files changed, 628 insertions(+), 513 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java index 348eb0715883..93bc37098e1e 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java @@ -30,6 +30,7 @@ import com.google.common.annotations.Beta; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; +import jakarta.annotation.Nonnull; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseConformance; import org.hl7.fhir.instance.model.api.IBaseParameters; @@ -721,7 +722,7 @@ default B /** * Returns the {@link FhirContext} used by the repository - * + *

* Practically, implementing FHIR functionality with the HAPI toolset requires a FhirContext. In * particular for things like version independent code. Ideally, a user could which FHIR version a * repository was configured for using things like the CapabilityStatement. In practice, that's @@ -730,6 +731,7 @@ default B * * @return a FhirContext */ + @Nonnull FhirContext fhirContext(); private static T throwNotImplementedOperationException(String theMessage) { diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java index 81a5ea350b31..9238e5608f10 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java @@ -4,12 +4,16 @@ import ca.uhn.fhir.model.api.IQueryParameterType; import ca.uhn.fhir.repository.matcher.IResourceMatcher; import ca.uhn.fhir.repository.matcher.MultiVersionResourceMatcher; +import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.server.exceptions.NotImplementedOperationException; import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; import ca.uhn.fhir.util.BundleBuilder; import ca.uhn.fhir.util.BundleUtil; +import ca.uhn.fhir.util.OperationOutcomeUtil; import com.google.common.collect.Multimap; +import jakarta.annotation.Nonnull; +import org.apache.commons.lang3.Validate; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseConformance; import org.hl7.fhir.instance.model.api.IBaseParameters; @@ -27,9 +31,9 @@ import java.util.function.Function; import java.util.stream.Collectors; +import static ca.uhn.fhir.model.api.StorageResponseCodeEnum.SUCCESSFUL_DELETE_NOT_FOUND; import static org.opencds.cqf.fhir.utility.BundleHelper.newBundle; - /** * An in-memory implementation of the FHIR repository interface. * Based on org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository. @@ -38,238 +42,283 @@ */ public class InMemoryFhirRepository implements IRepository { - private final Map> resourceMap; - private final FhirContext context; - private final IResourceMatcher resourceMatcher; + private final Map> resourceMap; + private final FhirContext context; + private final IResourceMatcher resourceMatcher; - public static InMemoryFhirRepository emptyRepository(FhirContext theFhirContext) { + public static InMemoryFhirRepository emptyRepository(@Nonnull FhirContext theFhirContext) { return new InMemoryFhirRepository(theFhirContext, new HashMap<>()); } public static InMemoryFhirRepository fromBundleContents(FhirContext theFhirContext, IBaseBundle theBundle) { - HashMap> contents = new HashMap<>(); - var resources = BundleUtil.toListOfResources(theFhirContext, theBundle); - contents.putAll(resources.stream() - .collect(Collectors.groupingBy( - IBaseResource::fhirType, - Collectors.toMap(r -> r.getIdElement().toUnqualifiedVersionless(), Function.identity())))); + List resources = BundleUtil.toListOfResources(theFhirContext, theBundle); + var bundleContents = resources.stream() + .collect(Collectors.groupingBy( + IBaseResource::fhirType, + Collectors.toMap(r -> r.getIdElement().toUnqualifiedVersionless(), Function.identity()))); - return new InMemoryFhirRepository(theFhirContext, contents); + return new InMemoryFhirRepository(theFhirContext, new HashMap<>(bundleContents)); } - InMemoryFhirRepository(FhirContext theContext, Map> theContents) { + InMemoryFhirRepository( + @Nonnull FhirContext theContext, @Nonnull Map> theContents) { context = theContext; resourceMap = theContents; resourceMatcher = new MultiVersionResourceMatcher(context); } - @Override - @SuppressWarnings("unchecked") - public T read( - Class resourceType, I id, Map headers) { - var resources = this.resourceMap.computeIfAbsent(resourceType.getSimpleName(), x -> new HashMap<>()); + record ResourceLookup(Map resources, IIdType id) { + @Nonnull + IBaseResource getResourceOrThrow404() { + var resource = resources.get(id); + + if (resource == null) { + throw new ResourceNotFoundException("Resource not found with id " + id); + } + return resource; + } + + void remove() { + resources.remove(id); + } - var resource = resources.get(id.toUnqualifiedVersionless()); + boolean isPresent() { + return resources.containsKey(id); + } + } - if (resource == null) { - throw new ResourceNotFoundException(id); - } + ResourceLookup lookupResource(Class theResourceType, IIdType theId) { + Validate.notNull(theResourceType, "Resource type must not be null"); + Validate.notNull(theId, "Id must not be null"); - return (T) resource; - } + String resourceTypeName = fhirContext().getResourceType(theResourceType); + Map resources = getResourceMapForType(resourceTypeName); - @Override - public MethodOutcome create(T resource, Map headers) { - var resources = resourceMap.computeIfAbsent(resource.fhirType(), r -> new HashMap<>()); + return new ResourceLookup(resources, theId.toUnqualifiedVersionless()); + } + + @Nonnull + private Map getResourceMapForType(String resourceTypeName) { + return resourceMap.computeIfAbsent(resourceTypeName, x -> new HashMap<>()); + } + + @Override + @SuppressWarnings("unchecked") + public T read( + Class resourceType, I id, Map headers) { + var lookup = lookupResource(resourceType, id); + + var resource = lookup.getResourceOrThrow404(); + + return (T) resource; + } + + @Override + public MethodOutcome create(T resource, Map headers) { + var resources = getResourceMapForType(resource.fhirType()); IIdType theId; do { theId = Ids.newRandomId(context, resource.fhirType()); } while (resources.containsKey(theId)); - resource.setId(theId); + resource.setId(theId); resources.put(theId.toUnqualifiedVersionless(), resource); return new MethodOutcome(theId, true); - } - - @Override - public MethodOutcome patch( - I id, P patchParameters, Map headers) { - throw new NotImplementedOperationException("The PATCH operation is not currently supported"); - } - - @Override - public MethodOutcome update(T resource, Map headers) { - var resources = resourceMap.computeIfAbsent(resource.fhirType(), r -> new HashMap<>()); - var theId = resource.getIdElement().toUnqualifiedVersionless(); - var outcome = new MethodOutcome(theId, false); - if (!resources.containsKey(theId)) { - outcome.setCreated(true); - } - if (resource.fhirType().equals("SearchParameter")) { - this.resourceMatcher.addCustomParameter(BundleHelper.resourceToRuntimeSearchParam(resource)); - } - resources.put(theId, resource); - - return outcome; - } - - @Override - public MethodOutcome delete( - Class resourceType, I id, Map headers) { - var resources = resourceMap.computeIfAbsent(id.getResourceType(), r -> new HashMap<>()); - var keyId = id.toUnqualifiedVersionless(); - if (resources.containsKey(keyId)) { - resources.remove(keyId); - return new MethodOutcome(id, false) - .setResource(resources.get(keyId)); - } else { - throw new ResourceNotFoundException("Resource not found with id " + id); - } - } + } + + @Override + public MethodOutcome patch( + I id, P patchParameters, Map headers) { + throw new NotImplementedOperationException("The PATCH operation is not currently supported"); + } @Override - public B search(Class bundleType, Class resourceType, Multimap> searchParameters, Map headers) { - BundleBuilder builder = new BundleBuilder(this.context); - var resourceIdMap = resourceMap.computeIfAbsent(resourceType.getSimpleName(), r -> new HashMap<>()); + public MethodOutcome update(T resource, Map headers) { + var resources = getResourceMapForType(resource.fhirType()); + var theId = resource.getIdElement().toUnqualifiedVersionless(); + var outcome = new MethodOutcome(theId, false); + if (!resources.containsKey(theId)) { + outcome.setCreated(true); + } + if (resource.fhirType().equals("SearchParameter")) { + this.resourceMatcher.addCustomParameter(BundleHelper.resourceToRuntimeSearchParam(resource)); + } + resources.put(theId, resource); + + return outcome; + } - if (searchParameters == null || searchParameters.isEmpty()) { - resourceIdMap.values().forEach(builder::addCollectionEntry); - builder.setType("searchset"); - return (B) builder.getBundle(); - } + @Override + public MethodOutcome delete( + Class resourceType, I id, Map headers) { + var lookup = lookupResource(resourceType, id); + + if (lookup.isPresent()) { + var resource = lookup.getResourceOrThrow404(); + lookup.remove(); + return new MethodOutcome(id, false).setResource(resource); + } else { + var oo = OperationOutcomeUtil.createOperationOutcome( + OperationOutcomeUtil.OO_SEVERITY_WARN, + SUCCESSFUL_DELETE_NOT_FOUND.getDisplay(), + "not-found", + fhirContext(), + SUCCESSFUL_DELETE_NOT_FOUND); + + MethodOutcome methodOutcome = new MethodOutcome(id, false).setOperationOutcome(oo); + methodOutcome.setResponseStatusCode(Constants.STATUS_HTTP_404_NOT_FOUND); + return methodOutcome; + } + } - Collection candidates = resourceIdMap.values(); + @Override + public B search( + Class bundleType, + Class resourceType, + Multimap> searchParameters, + Map headers) { + BundleBuilder builder = new BundleBuilder(this.context); + var resourceIdMap = resourceMap.computeIfAbsent(resourceType.getSimpleName(), r -> new HashMap<>()); + + if (searchParameters == null || searchParameters.isEmpty()) { + resourceIdMap.values().forEach(builder::addCollectionEntry); + builder.setType("searchset"); + return (B) builder.getBundle(); + } + + Collection candidates = resourceIdMap.values(); // fixme -// if (searchParameters.containsKey("_id")) { -// // We are consuming the _id parameter in this if statement -// var idQueries = searchParameters.get("_id"); -// searchParameters.remove("_id"); -// -// // The _id param can be a list of ids -// var idResources = new ArrayList(idQueries.size()); -// for (var idQuery : idQueries) { -// var idToken = (TokenParam) idQuery; -// // Need to construct the equivalent "UnqualifiedVersionless" id that the map is -// // indexed by. If an id has a version it won't match. Need apples-to-apples Ids types -// var id = Ids.newId(context, resourceType.getSimpleName(), idToken.getValue()); -// var r = resourceIdMap.get(id); -// if (r != null) { -// idResources.add(r); -// } -// } -// -// candidates = idResources; -// } else { -// candidates = resourceIdMap.values(); -// } - - // Apply the rest of the filters -// for (var resource : candidates) { -// boolean include = true; -// for (var nextEntry : searchParameters.entrySet()) { -// var paramName = nextEntry.getKey(); -// if (!this.resourceMatcher.matches(paramName, nextEntry.getValue(), resource)) { -// include = false; -// break; -// } -// } -// -// if (include) { -// builder.addCollectionEntry(resource); -// } -// } - - builder.setType("searchset"); - return (B) builder.getBundle(); - } - - @Override - public B link(Class bundleType, String url, Map headers) { - throw new NotImplementedOperationException("Paging is not currently supported"); - } - - @Override - public C capabilities(Class resourceType, Map headers) { - throw new NotImplementedOperationException("The capabilities interaction is not currently supported"); - } - - @Override - public B transaction(B transaction, Map headers) { - var version = transaction.getStructureFhirVersionEnum(); - - @SuppressWarnings("unchecked") - var returnBundle = (B) newBundle(version); - BundleHelper.getEntry(transaction).forEach(e -> { - if (BundleHelper.isEntryRequestPut(version, e)) { - var outcome = this.update(BundleHelper.getEntryResource(version, e)); - var location = outcome.getId().getValue(); - BundleHelper.addEntry( - returnBundle, - BundleHelper.newEntryWithResponse( - version, BundleHelper.newResponseWithLocation(version, location))); - } else if (BundleHelper.isEntryRequestPost(version, e)) { - var outcome = this.create(BundleHelper.getEntryResource(version, e)); - var location = outcome.getId().getValue(); - BundleHelper.addEntry( - returnBundle, - BundleHelper.newEntryWithResponse( - version, BundleHelper.newResponseWithLocation(version, location))); - } else if (BundleHelper.isEntryRequestDelete(version, e)) { - if (BundleHelper.getEntryRequestId(version, e).isPresent()) { - var resourceType = Canonicals.getResourceType( - ((BundleEntryComponent) e).getRequest().getUrl()); - var resourceClass = - this.context.getResourceDefinition(resourceType).getImplementingClass(); - var res = this.delete( - resourceClass, - BundleHelper.getEntryRequestId(version, e).get().withResourceType(resourceType)); - BundleHelper.addEntry(returnBundle, BundleHelper.newEntryWithResource(res.getResource())); - } else { - throw new ResourceNotFoundException("Trying to delete an entry without id"); - } - - } else { - throw new NotImplementedOperationException("Transaction stub only supports PUT, POST or DELETE"); - } - }); - - return returnBundle; - } + // if (searchParameters.containsKey("_id")) { + // // We are consuming the _id parameter in this if statement + // var idQueries = searchParameters.get("_id"); + // searchParameters.remove("_id"); + // + // // The _id param can be a list of ids + // var idResources = new ArrayList(idQueries.size()); + // for (var idQuery : idQueries) { + // var idToken = (TokenParam) idQuery; + // // Need to construct the equivalent "UnqualifiedVersionless" id that the map is + // // indexed by. If an id has a version it won't match. Need apples-to-apples Ids types + // var id = Ids.newId(context, resourceType.getSimpleName(), idToken.getValue()); + // var r = resourceIdMap.get(id); + // if (r != null) { + // idResources.add(r); + // } + // } + // + // candidates = idResources; + // } else { + // candidates = resourceIdMap.values(); + // } + + // Apply the rest of the filters + // for (var resource : candidates) { + // boolean include = true; + // for (var nextEntry : searchParameters.entrySet()) { + // var paramName = nextEntry.getKey(); + // if (!this.resourceMatcher.matches(paramName, nextEntry.getValue(), resource)) { + // include = false; + // break; + // } + // } + // + // if (include) { + // builder.addCollectionEntry(resource); + // } + // } + + builder.setType("searchset"); + return (B) builder.getBundle(); + } + + @Override + public B link(Class bundleType, String url, Map headers) { + throw new NotImplementedOperationException("Paging is not currently supported"); + } + + @Override + public C capabilities(Class resourceType, Map headers) { + throw new NotImplementedOperationException("The capabilities interaction is not currently supported"); + } + + @Override + public B transaction(B transaction, Map headers) { + var version = transaction.getStructureFhirVersionEnum(); + + @SuppressWarnings("unchecked") + var returnBundle = (B) newBundle(version); + BundleHelper.getEntry(transaction).forEach(e -> { + if (BundleHelper.isEntryRequestPut(version, e)) { + var outcome = this.update(BundleHelper.getEntryResource(version, e)); + var location = outcome.getId().getValue(); + BundleHelper.addEntry( + returnBundle, + BundleHelper.newEntryWithResponse( + version, BundleHelper.newResponseWithLocation(version, location))); + } else if (BundleHelper.isEntryRequestPost(version, e)) { + var outcome = this.create(BundleHelper.getEntryResource(version, e)); + var location = outcome.getId().getValue(); + BundleHelper.addEntry( + returnBundle, + BundleHelper.newEntryWithResponse( + version, BundleHelper.newResponseWithLocation(version, location))); + } else if (BundleHelper.isEntryRequestDelete(version, e)) { + if (BundleHelper.getEntryRequestId(version, e).isPresent()) { + var resourceType = Canonicals.getResourceType( + ((BundleEntryComponent) e).getRequest().getUrl()); + var resourceClass = + this.context.getResourceDefinition(resourceType).getImplementingClass(); + var res = this.delete( + resourceClass, + BundleHelper.getEntryRequestId(version, e).get().withResourceType(resourceType)); + BundleHelper.addEntry(returnBundle, BundleHelper.newEntryWithResource(res.getResource())); + } else { + throw new ResourceNotFoundException("Trying to delete an entry without id"); + } + + } else { + throw new NotImplementedOperationException("Transaction stub only supports PUT, POST or DELETE"); + } + }); + + return returnBundle; + } @Override - public R invoke(Class resourceType, String name, P parameters, Class returnType, Map headers) { + public R invoke( + Class resourceType, String name, P parameters, Class returnType, Map headers) { return null; } @Override - public R invoke(I id, String name, P parameters, Class returnType, Map headers) { + public R invoke( + I id, String name, P parameters, Class returnType, Map headers) { return null; } @Override - public B history( - P parameters, Class returnType, Map headers) { - throw new NotImplementedOperationException("The history interaction is not currently supported"); - } - - @Override - public B history( - Class resourceType, P parameters, Class returnType, Map headers) { - throw new NotImplementedOperationException("The history interaction is not currently supported"); - } - - @Override - public B history( - I id, P parameters, Class returnType, Map headers) { - throw new NotImplementedOperationException("The history interaction is not currently supported"); - } - - @Override - public FhirContext fhirContext() { - return this.context; - } + public B history( + P parameters, Class returnType, Map headers) { + throw new NotImplementedOperationException("The history interaction is not currently supported"); + } + + @Override + public B history( + Class resourceType, P parameters, Class returnType, Map headers) { + throw new NotImplementedOperationException("The history interaction is not currently supported"); + } + + @Override + public B history( + I id, P parameters, Class returnType, Map headers) { + throw new NotImplementedOperationException("The history interaction is not currently supported"); + } + @Override + public @Nonnull FhirContext fhirContext() { + return this.context; + } } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/IResourceMatcher.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/IResourceMatcher.java index d63a6166a193..009733176e7e 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/IResourceMatcher.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/IResourceMatcher.java @@ -28,252 +28,249 @@ public interface IResourceMatcher { - record SPPathKey ( - @Nonnull String resourceType, - @Nonnull String resourcePath) { + record SPPathKey(@Nonnull String resourceType, @Nonnull String resourcePath) { public String path() { return resourcePath; } - }; - - public IFhirPath getEngine(); - - public FhirContext getContext(); - - public Map getPathCache(); - - public void addCustomParameter(RuntimeSearchParam searchParam); - - public Map getCustomParameters(); - - // The list here is an OR list. Meaning, if any element matches it's a match - default boolean matches(String name, List params, IBaseResource resource) { - boolean match = true; - - var context = getContext(); - var s = context.getResourceDefinition(resource).getSearchParam(name); - if (s == null) { - s = this.getCustomParameters().get(name); - } - if (s == null) { - throw new RuntimeException(String.format( - "The SearchParameter %s for Resource %s is not supported.", name, resource.fhirType())); - } - - var path = s.getPath(); - - // System search parameters... - if (path.isEmpty() && name.startsWith("_")) { - path = name.substring(1); - } - - List pathResult = null; - try { - var parsed = getPathCache().computeIfAbsent(new SPPathKey(resource.fhirType(), path), p -> { - try { - return getEngine().parse(p.path()); - } catch (Exception e) { - throw new RuntimeException( - String.format( - "Parsing SearchParameter %s for Resource %s resulted in an error.", - name, resource.fhirType()), - e); - } - }); - pathResult = getEngine().evaluate(resource, parsed, IBase.class); - } catch (Exception e) { - throw new RuntimeException( - String.format( - "Evaluating SearchParameter %s for Resource %s resulted in an error.", - name, resource.fhirType()), - e); - } - - if (pathResult == null || pathResult.isEmpty()) { - return false; - } - - for (IQueryParameterType param : params) { - for (var r : pathResult) { - if (param instanceof ReferenceParam) { - match = isMatchReference(param, r); - } else if (param instanceof DateParam date) { - match = isMatchDate(date, r); - } else if (param instanceof TokenParam token) { - // [parameter]=[code]: the value of [code] matches a Coding.code or Identifier.value irrespective of - // the value of the system property - // [parameter]=[system]|[code]: the value of [code] matches a Coding.code or Identifier.value, and - // the value of [system] matches the system property of the Identifier or Coding - // [parameter]=|[code]: the value of [code] matches a Coding.code or Identifier.value, and the - // Coding/Identifier has no system property - // [parameter]=[system]|: any element where the value of [system] matches the system property of the - // Identifier or Coding - match = applyModifiers(isMatchToken(token, r), token); - if (!match) { - var codes = getCodes(r); - match = isMatchCoding(token, r, codes); - } - } else if (param instanceof UriParam uri) { - match = isMatchUri(uri, r); - } else if (param instanceof StringParam string) { - match = isMatchString(string, r); - } else { - throw new NotImplementedException("Resource matching not implemented for search params of type " - + param.getClass().getSimpleName()); - } - - if (match) { - return true; - } - } - } - - return false; - } - - private static boolean applyModifiers(boolean input, TokenParam token) { - if (token.getModifier() != null && token.getModifier() != TokenParamModifier.NOT) { - throw new NotImplementedException("Only the NOT modifier is supported on tokens at this time"); - } - if (token.getModifier() == TokenParamModifier.NOT) { - return !input; - } - return input; - } - - default boolean isMatchReference(IQueryParameterType param, IBase pathResult) { - if (pathResult instanceof IBaseReference) { - return ((IBaseReference) pathResult) - .getReferenceElement() - .getValue() - .equals(((ReferenceParam) param).getValue()); - } else if (pathResult instanceof IPrimitiveType) { - return ((IPrimitiveType) pathResult).getValueAsString().equals(((ReferenceParam) param).getValue()); - } else if (pathResult instanceof Iterable) { - for (var element : (Iterable) pathResult) { - if (element instanceof IBaseReference - && ((IBaseReference) element) - .getReferenceElement() - .getValue() - .equals(((ReferenceParam) param).getValue())) { - return true; - } - if (element instanceof IPrimitiveType - && ((IPrimitiveType) element) - .getValueAsString() - .equals(((ReferenceParam) param).getValue())) { - return true; - } - } - } else { - throw new UnsupportedOperationException( - "Expected Reference element, found " + pathResult.getClass().getSimpleName()); - } - return false; - } - - default boolean isMatchDate(DateParam param, IBase pathResult) { - DateRangeParam dateRange; - // date, dateTime and instant are PrimitiveType - if (pathResult instanceof IPrimitiveType) { - var result = ((IPrimitiveType) pathResult).getValue(); - if (result instanceof Date) { - dateRange = new DateRangeParam((Date) result, (Date) result); - } else { - throw new UnsupportedOperationException( - "Expected date, found " + pathResult.getClass().getSimpleName()); - } - } else if (pathResult instanceof ICompositeType) { - dateRange = getDateRange((ICompositeType) pathResult); - } else { - throw new UnsupportedOperationException( - "Expected element of type date, dateTime, instant, Timing or Period, found " - + pathResult.getClass().getSimpleName()); - } - return matchesDateBounds(dateRange, new DateRangeParam(param)); - } - - default boolean isMatchToken(TokenParam param, IBase pathResult) { - if (param.getValue() == null) { - return true; - } - - if (pathResult instanceof IIdType) { - var id = (IIdType) pathResult; - return param.getValue().equals(id.getIdPart()); - } - - if (pathResult instanceof IBaseEnumeration) { - return param.getValue().equals(((IBaseEnumeration) pathResult).getValueAsString()); - } - - if (pathResult instanceof IPrimitiveType) { - return param.getValue().equals(((IPrimitiveType) pathResult).getValue()); - } - - return false; - } - - default boolean isMatchCoding(TokenParam param, IBase pathResult, List codes) { - if (codes == null || codes.isEmpty()) { - return false; - } - - if (param.getModifier() == TokenParamModifier.IN) { - throw new UnsupportedOperationException("In modifier is unsupported"); - } - - for (var c : codes) { - var matches = param.getValue().equals(c.getValue()) - && (param.getSystem() == null || param.getSystem().equals(c.getSystem())); - if (matches) { - return true; - } - } - - return false; - } - - default boolean isMatchUri(UriParam param, IBase pathResult) { - if (pathResult instanceof IPrimitiveType) { - return param.getValue().equals(((IPrimitiveType) pathResult).getValue()); - } - throw new UnsupportedOperationException("Expected element of type url or uri, found " - + pathResult.getClass().getSimpleName()); - } - - default boolean isMatchString(StringParam param, Object pathResult) { - if (pathResult instanceof IPrimitiveType) { - return param.getValue().equals(((IPrimitiveType) pathResult).getValue()); - } - throw new UnsupportedOperationException("Expected element of type string, found " - + pathResult.getClass().getSimpleName()); - } - - default boolean matchesDateBounds(DateRangeParam resourceRange, DateRangeParam paramRange) { - Date resourceLowerBound = resourceRange.getLowerBoundAsInstant(); - Date resourceUpperBound = resourceRange.getUpperBoundAsInstant(); - Date paramLowerBound = paramRange.getLowerBoundAsInstant(); - Date paramUpperBound = paramRange.getUpperBoundAsInstant(); - if (paramLowerBound == null && paramUpperBound == null) { - return false; - } else { - boolean result = true; - if (paramLowerBound != null) { - result &= resourceLowerBound.after(paramLowerBound) || resourceLowerBound.equals(paramLowerBound); - result &= resourceUpperBound.after(paramLowerBound) || resourceUpperBound.equals(paramLowerBound); - } - - if (paramUpperBound != null) { - result &= resourceLowerBound.before(paramUpperBound) || resourceLowerBound.equals(paramUpperBound); - result &= resourceUpperBound.before(paramUpperBound) || resourceUpperBound.equals(paramUpperBound); - } - - return result; - } - } - - DateRangeParam getDateRange(ICompositeType type); - - List getCodes(IBase codeElement); + } + + IFhirPath getEngine(); + + FhirContext getContext(); + + Map getExpressionCache(); + + void addCustomParameter(RuntimeSearchParam searchParam); + + Map getCustomParameters(); + + // The list here is an OR list. Meaning, if any element matches it's a match + default boolean matches(String name, List params, IBaseResource resource) { + boolean match = true; + + var context = getContext(); + var s = context.getResourceDefinition(resource).getSearchParam(name); + if (s == null) { + s = this.getCustomParameters().get(name); + } + if (s == null) { + throw new RuntimeException(String.format( + "The SearchParameter %s for Resource %s is not supported.", name, resource.fhirType())); + } + + var path = s.getPath(); + + // System search parameters... + if (path.isEmpty() && name.startsWith("_")) { + path = name.substring(1); + } + + List pathResult = null; + try { + var parsed = getExpressionCache().computeIfAbsent(new SPPathKey(resource.fhirType(), path), p -> { + try { + return getEngine().parse(p.path()); + } catch (Exception e) { + throw new RuntimeException( + String.format( + "Parsing SearchParameter %s for Resource %s resulted in an error.", + name, resource.fhirType()), + e); + } + }); + pathResult = getEngine().evaluate(resource, parsed, IBase.class); + } catch (Exception e) { + throw new RuntimeException( + String.format( + "Evaluating SearchParameter %s for Resource %s resulted in an error.", + name, resource.fhirType()), + e); + } + + if (pathResult == null || pathResult.isEmpty()) { + return false; + } + + for (IQueryParameterType param : params) { + for (var r : pathResult) { + if (param instanceof ReferenceParam) { + match = isMatchReference(param, r); + } else if (param instanceof DateParam date) { + match = isMatchDate(date, r); + } else if (param instanceof TokenParam token) { + // [parameter]=[code]: the value of [code] matches a Coding.code or Identifier.value irrespective of + // the value of the system property + // [parameter]=[system]|[code]: the value of [code] matches a Coding.code or Identifier.value, and + // the value of [system] matches the system property of the Identifier or Coding + // [parameter]=|[code]: the value of [code] matches a Coding.code or Identifier.value, and the + // Coding/Identifier has no system property + // [parameter]=[system]|: any element where the value of [system] matches the system property of the + // Identifier or Coding + match = applyModifiers(isMatchToken(token, r), token); + if (!match) { + var codes = getCodes(r); + match = isMatchCoding(token, r, codes); + } + } else if (param instanceof UriParam uri) { + match = isMatchUri(uri, r); + } else if (param instanceof StringParam string) { + match = isMatchString(string, r); + } else { + throw new NotImplementedException("Resource matching not implemented for search params of type " + + param.getClass().getSimpleName()); + } + + if (match) { + return true; + } + } + } + + return false; + } + + private static boolean applyModifiers(boolean input, TokenParam token) { + if (token.getModifier() != null && token.getModifier() != TokenParamModifier.NOT) { + throw new NotImplementedException("Only the NOT modifier is supported on tokens at this time"); + } + if (token.getModifier() == TokenParamModifier.NOT) { + return !input; + } + return input; + } + + default boolean isMatchReference(IQueryParameterType param, IBase pathResult) { + if (pathResult instanceof IBaseReference) { + return ((IBaseReference) pathResult) + .getReferenceElement() + .getValue() + .equals(((ReferenceParam) param).getValue()); + } else if (pathResult instanceof IPrimitiveType) { + return ((IPrimitiveType) pathResult).getValueAsString().equals(((ReferenceParam) param).getValue()); + } else if (pathResult instanceof Iterable) { + for (var element : (Iterable) pathResult) { + if (element instanceof IBaseReference + && ((IBaseReference) element) + .getReferenceElement() + .getValue() + .equals(((ReferenceParam) param).getValue())) { + return true; + } + if (element instanceof IPrimitiveType + && ((IPrimitiveType) element) + .getValueAsString() + .equals(((ReferenceParam) param).getValue())) { + return true; + } + } + } else { + throw new UnsupportedOperationException( + "Expected Reference element, found " + pathResult.getClass().getSimpleName()); + } + return false; + } + + default boolean isMatchDate(DateParam param, IBase pathResult) { + DateRangeParam dateRange; + // date, dateTime and instant are PrimitiveType + if (pathResult instanceof IPrimitiveType) { + var result = ((IPrimitiveType) pathResult).getValue(); + if (result instanceof Date) { + dateRange = new DateRangeParam((Date) result, (Date) result); + } else { + throw new UnsupportedOperationException( + "Expected date, found " + pathResult.getClass().getSimpleName()); + } + } else if (pathResult instanceof ICompositeType) { + dateRange = getDateRange((ICompositeType) pathResult); + } else { + throw new UnsupportedOperationException( + "Expected element of type date, dateTime, instant, Timing or Period, found " + + pathResult.getClass().getSimpleName()); + } + return matchesDateBounds(dateRange, new DateRangeParam(param)); + } + + default boolean isMatchToken(TokenParam param, IBase pathResult) { + if (param.getValue() == null) { + return true; + } + + if (pathResult instanceof IIdType id) { + return param.getValue().equals(id.getIdPart()); + } + + if (pathResult instanceof IBaseEnumeration) { + return param.getValue().equals(((IBaseEnumeration) pathResult).getValueAsString()); + } + + if (pathResult instanceof IPrimitiveType) { + return param.getValue().equals(((IPrimitiveType) pathResult).getValue()); + } + + return false; + } + + default boolean isMatchCoding(TokenParam param, IBase pathResult, List codes) { + if (codes == null || codes.isEmpty()) { + return false; + } + + if (param.getModifier() == TokenParamModifier.IN) { + throw new UnsupportedOperationException("In modifier is unsupported"); + } + + for (var c : codes) { + var matches = param.getValue().equals(c.getValue()) + && (param.getSystem() == null || param.getSystem().equals(c.getSystem())); + if (matches) { + return true; + } + } + + return false; + } + + default boolean isMatchUri(UriParam param, IBase pathResult) { + if (pathResult instanceof IPrimitiveType) { + return param.getValue().equals(((IPrimitiveType) pathResult).getValue()); + } + throw new UnsupportedOperationException("Expected element of type url or uri, found " + + pathResult.getClass().getSimpleName()); + } + + default boolean isMatchString(StringParam param, Object pathResult) { + if (pathResult instanceof IPrimitiveType) { + return param.getValue().equals(((IPrimitiveType) pathResult).getValue()); + } + throw new UnsupportedOperationException("Expected element of type string, found " + + pathResult.getClass().getSimpleName()); + } + + default boolean matchesDateBounds(DateRangeParam resourceRange, DateRangeParam paramRange) { + Date resourceLowerBound = resourceRange.getLowerBoundAsInstant(); + Date resourceUpperBound = resourceRange.getUpperBoundAsInstant(); + Date paramLowerBound = paramRange.getLowerBoundAsInstant(); + Date paramUpperBound = paramRange.getUpperBoundAsInstant(); + if (paramLowerBound == null && paramUpperBound == null) { + return false; + } else { + boolean result = true; + if (paramLowerBound != null) { + result &= resourceLowerBound.after(paramLowerBound) || resourceLowerBound.equals(paramLowerBound); + result &= resourceUpperBound.after(paramLowerBound) || resourceUpperBound.equals(paramLowerBound); + } + + if (paramUpperBound != null) { + result &= resourceLowerBound.before(paramUpperBound) || resourceLowerBound.equals(paramUpperBound); + result &= resourceUpperBound.before(paramUpperBound) || resourceUpperBound.equals(paramUpperBound); + } + + return result; + } + } + + DateRangeParam getDateRange(ICompositeType type); + + List getCodes(IBase codeElement); } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java index 0dd8b8b21feb..6d69f34be6e4 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java @@ -13,16 +13,16 @@ import org.hl7.fhir.instance.model.api.ICompositeType; import org.hl7.fhir.instance.model.api.IPrimitiveType; -import javax.annotation.Nonnull; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.Nonnull; public class MultiVersionResourceMatcher implements IResourceMatcher { private static final Map ourFhirPathCache = new ConcurrentHashMap<>(); - private Map myPathCache = new HashMap<>(); - private Map myCustomSearchParams = new HashMap<>(); + private final Map myExpressionCache = new HashMap<>(); + private final Map myCustomSearchParams = new HashMap<>(); private final FhirContext myFhirContext; @@ -41,8 +41,8 @@ public FhirContext getContext() { } @Override - public Map getPathCache() { - return myPathCache; + public Map getExpressionCache() { + return myExpressionCache; } @Override @@ -63,13 +63,14 @@ public DateRangeParam getDateRange(ICompositeType type) { throw new NotImplementedException("Timing resolution has not yet been implemented"); } else { throw new UnsupportedOperationException("Expected element of type Period or Timing, found " - + type.getClass().getSimpleName()); + + type.getClass().getSimpleName()); } } @Override public List getCodes(IBase theCodeElement) { - String elementTypeName = myFhirContext.getElementDefinition(theCodeElement.getClass()).getName(); + String elementTypeName = + myFhirContext.getElementDefinition(theCodeElement.getClass()).getName(); switch (elementTypeName) { case "Coding" -> { var terser = myFhirContext.newTerser(); @@ -80,15 +81,16 @@ public List getCodes(IBase theCodeElement) { String codeValue = ((IPrimitiveType) theCodeElement).getValueAsString(); return List.of(new TokenParam(codeValue)); } - case "CodeableConcept"-> { + case "CodeableConcept" -> { var terser = myFhirContext.newTerser(); - return terser.getValues(theCodeElement, "codeing").stream().map(coding -> - codingToTokenParam(terser, coding)).toList(); - } - default -> - throw new UnsupportedOperationException("Expected element of type Coding, CodeType, or CodeableConcept, found " + elementTypeName); + return terser.getValues(theCodeElement, "codeing").stream() + .map(coding -> codingToTokenParam(terser, coding)) + .toList(); } + default -> throw new UnsupportedOperationException( + "Expected element of type Coding, CodeType, or CodeableConcept, found " + elementTypeName); } + } @Nonnull private static TokenParam codingToTokenParam(FhirTerser theTerser, IBase theCodeElement) { @@ -97,5 +99,4 @@ private static TokenParam codingToTokenParam(FhirTerser theTerser, IBase theCode return new TokenParam(system, code); } - } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/OperationOutcomeUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/OperationOutcomeUtil.java index c910e4ce4758..532f9656bed7 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/OperationOutcomeUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/OperationOutcomeUtil.java @@ -26,6 +26,7 @@ import ca.uhn.fhir.context.FhirVersionEnum; import ca.uhn.fhir.context.RuntimeResourceDefinition; import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.model.api.StorageResponseCodeEnum; import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; import jakarta.annotation.Nullable; @@ -45,6 +46,11 @@ */ public class OperationOutcomeUtil { + public static final String OO_SEVERITY_ERROR = "error"; + public static final String OO_SEVERITY_INFO = "information"; + public static final String OO_SEVERITY_WARN = "warning"; + public static final String OO_ISSUE_CODE_INFORMATIONAL = "informational"; + /** * Add an issue to an OperationOutcome * @@ -380,4 +386,32 @@ public static void addMessageIdExtensionToIssue(FhirContext theCtx, IBase theIss theMessageId); } } + + public static IBaseOperationOutcome createOperationOutcome( + String theSeverity, + String theMessage, + String theCode, + FhirContext theFhirContext, + @Nullable StorageResponseCodeEnum theStorageResponseCode) { + IBaseOperationOutcome oo = newInstance(theFhirContext); + String detailSystem = null; + String detailCode = null; + String detailDescription = null; + if (theStorageResponseCode != null) { + detailSystem = theStorageResponseCode.getSystem(); + detailCode = theStorageResponseCode.getCode(); + detailDescription = theStorageResponseCode.getDisplay(); + } + addIssue( + theFhirContext, + oo, + theSeverity, + theMessage, + null, + theCode, + detailSystem, + detailCode, + detailDescription); + return oo; + } } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java index 42d3f110237b..90c18ca95b00 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java @@ -1025,8 +1025,7 @@ public void beforeCommit(boolean readOnly) { String msg = getContext() .getLocalizer() .getMessageSanitized(BaseStorageDao.class, "unableToDeleteNotFound", theUrl); - oo = createOperationOutcome( - OO_SEVERITY_WARN, msg, "not-found", StorageResponseCodeEnum.SUCCESSFUL_DELETE_NOT_FOUND); + oo = createWarnOperationOutcome(msg, "not-found", StorageResponseCodeEnum.SUCCESSFUL_DELETE_NOT_FOUND); } else { String msg = getContext() .getLocalizer() diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java index 071ca1282f3d..999972d3a305 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java @@ -4,7 +4,6 @@ import ca.uhn.fhir.jpa.model.entity.NormalizedQuantitySearchLevel; import ca.uhn.fhir.jpa.repository.HapiFhirRepository; import ca.uhn.fhir.jpa.test.BaseJpaR4Test; -import ca.uhn.fhir.repository.IRepository; import ca.uhn.fhir.repository.IRepositoryTest; import org.junit.jupiter.api.AfterEach; diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/BaseStorageDao.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/BaseStorageDao.java index fe33b2a3ad5c..b7cb287b1316 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/BaseStorageDao.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/BaseStorageDao.java @@ -100,9 +100,15 @@ public abstract class BaseStorageDao { private static final Logger ourLog = LoggerFactory.getLogger(BaseStorageDao.class); - public static final String OO_SEVERITY_ERROR = "error"; - public static final String OO_SEVERITY_INFO = "information"; - public static final String OO_SEVERITY_WARN = "warning"; + /** @deprecated moved to {@link OperationOutcomeUtil#OO_SEVERITY_ERROR} */ + @Deprecated(forRemoval = true, since = "8.4.0") + public static final String OO_SEVERITY_ERROR = OperationOutcomeUtil.OO_SEVERITY_ERROR; + /** @deprecated moved to {@link OperationOutcomeUtil#OO_SEVERITY_INFO} */ + @Deprecated(forRemoval = true, since = "8.4.0") + public static final String OO_SEVERITY_INFO = OperationOutcomeUtil.OO_SEVERITY_INFO; + /** @deprecated moved to {@link OperationOutcomeUtil#OO_SEVERITY_WARN} */ + @Deprecated(forRemoval = true, since = "8.4.0") + public static final String OO_SEVERITY_WARN = OperationOutcomeUtil.OO_SEVERITY_WARN; private static final String PROCESSING_SUB_REQUEST = "BaseStorageDao.processingSubRequest"; protected static final String MESSAGE_KEY_DELETE_RESOURCE_NOT_EXISTING = "deleteResourceNotExisting"; @@ -460,7 +466,7 @@ protected void doCallHooks( protected abstract IInterceptorBroadcaster getInterceptorBroadcaster(); public IBaseOperationOutcome createErrorOperationOutcome(String theMessage, String theCode) { - return createOperationOutcome(OO_SEVERITY_ERROR, theMessage, theCode); + return createOperationOutcome(OperationOutcomeUtil.OO_SEVERITY_ERROR, theMessage, theCode); } public IBaseOperationOutcome createInfoOperationOutcome(String theMessage) { @@ -469,31 +475,23 @@ public IBaseOperationOutcome createInfoOperationOutcome(String theMessage) { public IBaseOperationOutcome createInfoOperationOutcome( String theMessage, @Nullable StorageResponseCodeEnum theStorageResponseCode) { - return createOperationOutcome( - OO_SEVERITY_INFO, theMessage, OO_ISSUE_CODE_INFORMATIONAL, theStorageResponseCode); + return OperationOutcomeUtil.createOperationOutcome( + OperationOutcomeUtil.OO_SEVERITY_INFO, + theMessage, + OperationOutcomeUtil.OO_ISSUE_CODE_INFORMATIONAL, + getContext(), + theStorageResponseCode); } private IBaseOperationOutcome createOperationOutcome(String theSeverity, String theMessage, String theCode) { - return createOperationOutcome(theSeverity, theMessage, theCode, null); + return OperationOutcomeUtil.createOperationOutcome(theSeverity, theMessage, theCode, getContext(), null); } - protected IBaseOperationOutcome createOperationOutcome( - String theSeverity, - String theMessage, - String theCode, - @Nullable StorageResponseCodeEnum theStorageResponseCode) { - IBaseOperationOutcome oo = OperationOutcomeUtil.newInstance(getContext()); - String detailSystem = null; - String detailCode = null; - String detailDescription = null; - if (theStorageResponseCode != null) { - detailSystem = theStorageResponseCode.getSystem(); - detailCode = theStorageResponseCode.getCode(); - detailDescription = theStorageResponseCode.getDisplay(); - } - OperationOutcomeUtil.addIssue( - getContext(), oo, theSeverity, theMessage, null, theCode, detailSystem, detailCode, detailDescription); - return oo; + @Nonnull + public IBaseOperationOutcome createWarnOperationOutcome( + String theMsg, String theCode, StorageResponseCodeEnum theResponseCodeEnum) { + return OperationOutcomeUtil.createOperationOutcome( + OperationOutcomeUtil.OO_SEVERITY_WARN, theMsg, theCode, getContext(), theResponseCodeEnum); } /** @@ -514,7 +512,8 @@ protected DaoMethodOutcome createMethodOutcomeForResourceId( String message = getContext().getLocalizer().getMessage(BaseStorageDao.class, theMessageKey, id); String severity = "information"; String code = "informational"; - IBaseOperationOutcome oo = createOperationOutcome(severity, message, code, theStorageResponseCode); + IBaseOperationOutcome oo = OperationOutcomeUtil.createOperationOutcome( + severity, message, code, getContext(), theStorageResponseCode); outcome.setOperationOutcome(oo); return outcome; @@ -768,10 +767,10 @@ public static String addIssueToOperationOutcomeForAutoCreatedPlaceholder( IBase issue = OperationOutcomeUtil.addIssue( theFhirContext, theOperationOutcomeToPopulate, - OO_SEVERITY_INFO, + OperationOutcomeUtil.OO_SEVERITY_INFO, msg, null, - OO_ISSUE_CODE_INFORMATIONAL, + OperationOutcomeUtil.OO_ISSUE_CODE_INFORMATIONAL, detailSystem, detailCode, detailDescription); diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java index ade0b4e93983..6cd89f06f407 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java @@ -42,6 +42,7 @@ import ca.uhn.fhir.rest.server.method.PageMethodBinding; import ca.uhn.fhir.util.UrlUtil; import com.google.common.collect.Multimap; +import jakarta.annotation.Nonnull; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseConformance; import org.hl7.fhir.instance.model.api.IBaseParameters; @@ -152,7 +153,7 @@ public B search( } private B createBundle( - RequestDetails theRequestDetails, IBundleProvider theBundleProvider, String thePagingAction) { + RequestDetails theRequestDetails, @Nonnull IBundleProvider theBundleProvider, String thePagingAction) { Integer count = RestfulServerUtils.extractCountParameter(theRequestDetails); String linkSelf = RestfulServerUtils.createLinkSelf(theRequestDetails.getFhirServerBase(), theRequestDetails); @@ -173,7 +174,7 @@ private B createBundle( start = Math.max(0, Math.min(offset, theBundleProvider.size())); } - BundleTypeEnum bundleType = null; + BundleTypeEnum bundleType; String[] bundleTypeValues = theRequestDetails.getParameters().get(Constants.PARAM_BUNDLETYPE); if (bundleTypeValues != null) { bundleType = BundleTypeEnum.VALUESET_BINDER.fromCodeString(bundleTypeValues[0]); @@ -386,7 +387,7 @@ public B h } @Override - public FhirContext fhirContext() { + public @Nonnull FhirContext fhirContext() { return myDaoRegistry.getFhirContext(); } diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java index 6cf1b80e1240..0dba2890fcf2 100644 --- a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -1,32 +1,34 @@ package ca.uhn.fhir.repository; - +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.model.primitive.IdDt; import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; +import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException; import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; import ca.uhn.fhir.test.utilities.ITestDataBuilder; import ca.uhn.fhir.test.utilities.RepositoryTestDataBuilder; import ca.uhn.fhir.util.FhirTerser; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; -import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nonnull; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.fail; /** Generic test of repository functionality */ -@SuppressWarnings({ - "java:S5960" // this is a test jar +@SuppressWarnings({"java:S5960" // this is a test jar }) public interface IRepositoryTest { Logger ourLog = LoggerFactory.getLogger(IRepositoryTest.class); @Test default void testCreate_readById_contentsPersisted() { - // given + // given var b = getTestDataBuilder(); var patient = b.buildPatient(b.withBirthdate("1970-02-14")); IRepository repository = getRepository(); @@ -34,14 +36,15 @@ default void testCreate_readById_contentsPersisted() { // when MethodOutcome methodOutcome = repository.create(patient); ourLog.info("Created resource with id: {} created:{}", methodOutcome.getId(), methodOutcome.getCreated()); - IBaseResource read = repository.read(patient.getClass(), methodOutcome.getId().toVersionless()); + IBaseResource read = + repository.read(patient.getClass(), methodOutcome.getId().toVersionless()); // then - assertThat(read) - .isNotNull() - .extracting(p-> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) - .as("resource body read matches persisted value") - .isEqualTo("1970-02-14"); + assertThat(read) + .isNotNull() + .extracting(p -> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) + .as("resource body read matches persisted value") + .isEqualTo("1970-02-14"); } @Test @@ -85,14 +88,14 @@ default void testCreate_update_readById_verifyUpdatedContents() { // then assertThat(read) - .isNotNull() - .extracting(p -> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) - .as("resource body read matches updated value") - .isEqualTo(updatedBirthdate); + .isNotNull() + .extracting(p -> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) + .as("resource body read matches updated value") + .isEqualTo(updatedBirthdate); } @Test - default void testCreate_delete_readById_throwsNotFound() { + default void testCreate_delete_readById_throwsException() { // given a patient resource var b = getTestDataBuilder(); var patient = b.buildPatient(b.withBirthdate("1970-02-14")); @@ -101,27 +104,58 @@ default void testCreate_delete_readById_throwsNotFound() { IIdType patientId = createOutcome.getId().toVersionless(); // when deleted - repository.delete(patient.getClass(), patientId); - - // then - read should throw ResourceNotFoundException - assertThrows(ResourceNotFoundException.class, () -> { - repository.read(patient.getClass(), patientId); - }); + var outcome = repository.delete(patient.getClass(), patientId); + + // then - read should throw ResourceNotFoundException or ResourceGoneException + // Repositories with history should probably throw ResourceGoneException + // But repositories without history can't tell the difference and will throw ResourceNotFoundException + var exception = + assertThrows(BaseServerResponseException.class, () -> repository.read(patient.getClass(), patientId)); + assertThat(exception).isInstanceOfAny(ResourceNotFoundException.class, ResourceGoneException.class); } - private FhirTerser getTerser() { - return getRepository().fhirContext().newTerser(); + @Test + default void testDelete_noCreate_returnsOutcome() { + // given + IRepository repository = getRepository(); + var patientClass = getTestDataBuilder().buildPatient().getClass(); + + // when + var outcome = repository.delete(patientClass, new IdDt("Patient/123")); + + // then + assertThat(outcome).isNotNull(); } + /** Implementors of this test template must provide a RepositoryTestSupport instance */ RepositoryTestSupport getRepositoryTestSupport(); - default ITestDataBuilder getTestDataBuilder() { - return RepositoryTestDataBuilder.forRepository(getRepository()); + record RepositoryTestSupport(IRepository repository) { + @Nonnull + public FhirTerser getFhirTerser() { + return getFhirContext().newTerser(); + } + + @Nonnull + public FhirContext getFhirContext() { + return repository().fhirContext(); + } + + @Nonnull + private RepositoryTestDataBuilder getRepositoryTestDataBuilder() { + return RepositoryTestDataBuilder.forRepository(repository()); + } } private IRepository getRepository() { return getRepositoryTestSupport().repository(); } - record RepositoryTestSupport(IRepository repository) { } + private ITestDataBuilder getTestDataBuilder() { + return getRepositoryTestSupport().getRepositoryTestDataBuilder(); + } + + private FhirTerser getTerser() { + return getRepositoryTestSupport().getFhirTerser(); + } } From 9cb9ec562d1dc36c31e990a6ee6377f4f4283def Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 4 Jul 2025 16:38:47 -0400 Subject: [PATCH 03/65] warnings + cleanup --- .../java/ca/uhn/fhir/context/BaseRuntimeElementDefinition.java | 2 ++ .../src/main/java/ca/uhn/fhir/repository/IRepository.java | 2 -- .../java/ca/uhn/fhir/repository/InMemoryFhirRepository.java | 2 ++ .../src/main/java/ca/uhn/fhir/jpa/dao/BaseStorageDao.java | 1 + .../src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java | 2 +- 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/context/BaseRuntimeElementDefinition.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/context/BaseRuntimeElementDefinition.java index 5d29195bb64b..cf5f940d0b3b 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/context/BaseRuntimeElementDefinition.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/context/BaseRuntimeElementDefinition.java @@ -163,10 +163,12 @@ public boolean isStandardType() { return myStandardType; } + @Nonnull public T newInstance() { return newInstance(null); } + @Nonnull public T newInstance(Object theArgument) { try { if (theArgument == null) { diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java index 93bc37098e1e..698a57419b86 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java @@ -314,7 +314,6 @@ default B search( /** * Reads a Bundle from a link on this repository - * * This is typically used for paging during searches * * @see FHIR Bundle @@ -330,7 +329,6 @@ default B link(Class bundleType, String url) { /** * Reads a Bundle from a link on this repository - * * This is typically used for paging during searches * * @see FHIR Bundle diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java index 9238e5608f10..d9dba8dd0c27 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java @@ -186,6 +186,7 @@ public B search( if (searchParameters == null || searchParameters.isEmpty()) { resourceIdMap.values().forEach(builder::addCollectionEntry); builder.setType("searchset"); + //noinspection unchecked return (B) builder.getBundle(); } @@ -231,6 +232,7 @@ public B search( // } builder.setType("searchset"); + //noinspection unchecked return (B) builder.getBundle(); } diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/BaseStorageDao.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/BaseStorageDao.java index b7cb287b1316..15765d864bec 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/BaseStorageDao.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/dao/BaseStorageDao.java @@ -109,6 +109,7 @@ public abstract class BaseStorageDao { /** @deprecated moved to {@link OperationOutcomeUtil#OO_SEVERITY_WARN} */ @Deprecated(forRemoval = true, since = "8.4.0") public static final String OO_SEVERITY_WARN = OperationOutcomeUtil.OO_SEVERITY_WARN; + private static final String PROCESSING_SUB_REQUEST = "BaseStorageDao.processingSubRequest"; protected static final String MESSAGE_KEY_DELETE_RESOURCE_NOT_EXISTING = "deleteResourceNotExisting"; diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java index 0dba2890fcf2..9bc4109f1c7a 100644 --- a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -104,7 +104,7 @@ default void testCreate_delete_readById_throwsException() { IIdType patientId = createOutcome.getId().toVersionless(); // when deleted - var outcome = repository.delete(patient.getClass(), patientId); + repository.delete(patient.getClass(), patientId); // then - read should throw ResourceNotFoundException or ResourceGoneException // Repositories with history should probably throw ResourceGoneException From f2721a53d965dd68cb28313602b17615ce1e0e33 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 4 Jul 2025 17:17:39 -0400 Subject: [PATCH 04/65] Normalize ids --- .../repository/InMemoryFhirRepository.java | 125 ++++++++++-------- .../matcher/MultiVersionResourceMatcher.java | 22 ++- .../uhn/fhir/repository/IRepositoryTest.java | 2 +- 3 files changed, 81 insertions(+), 68 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java index d9dba8dd0c27..f33a7e3132ac 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java @@ -2,6 +2,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.model.primitive.IdDt; import ca.uhn.fhir.repository.matcher.IResourceMatcher; import ca.uhn.fhir.repository.matcher.MultiVersionResourceMatcher; import ca.uhn.fhir.rest.api.Constants; @@ -11,6 +12,7 @@ import ca.uhn.fhir.util.BundleBuilder; import ca.uhn.fhir.util.BundleUtil; import ca.uhn.fhir.util.OperationOutcomeUtil; +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Multimap; import jakarta.annotation.Nonnull; import org.apache.commons.lang3.Validate; @@ -19,20 +21,16 @@ import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; -import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent; -import org.opencds.cqf.fhir.utility.BundleHelper; -import org.opencds.cqf.fhir.utility.Canonicals; -import org.opencds.cqf.fhir.utility.Ids; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import static ca.uhn.fhir.model.api.StorageResponseCodeEnum.SUCCESSFUL_DELETE_NOT_FOUND; -import static org.opencds.cqf.fhir.utility.BundleHelper.newBundle; /** * An in-memory implementation of the FHIR repository interface. @@ -86,6 +84,10 @@ void remove() { boolean isPresent() { return resources.containsKey(id); } + + public void put(T theResource) { + resources.put(id, theResource); + } } ResourceLookup lookupResource(Class theResourceType, IIdType theId) { @@ -93,14 +95,19 @@ ResourceLookup lookupResource(Class theResourceType, II Validate.notNull(theId, "Id must not be null"); String resourceTypeName = fhirContext().getResourceType(theResourceType); - Map resources = getResourceMapForType(resourceTypeName); - return new ResourceLookup(resources, theId.toUnqualifiedVersionless()); - } + IIdType unqualifiedVersionless = theId.toUnqualifiedVersionless(); + String idResourceType = unqualifiedVersionless.getResourceType(); + if (idResourceType == null) { + unqualifiedVersionless = unqualifiedVersionless.withResourceType(resourceTypeName); + } else if (!idResourceType.equals(resourceTypeName)) { + throw new IllegalArgumentException( + "Resource type mismatch: resource is " + resourceTypeName + " but id type is " + idResourceType); + } - @Nonnull - private Map getResourceMapForType(String resourceTypeName) { - return resourceMap.computeIfAbsent(resourceTypeName, x -> new HashMap<>()); + Map resources = getResourceMapForType(unqualifiedVersionless.getResourceType()); + + return new ResourceLookup(resources, new IdDt(unqualifiedVersionless)); } @Override @@ -120,7 +127,7 @@ public MethodOutcome create(T resource, Map MethodOutcome patch( @Override public MethodOutcome update(T resource, Map headers) { - var resources = getResourceMapForType(resource.fhirType()); - var theId = resource.getIdElement().toUnqualifiedVersionless(); - var outcome = new MethodOutcome(theId, false); - if (!resources.containsKey(theId)) { + var lookup = lookupResource(resource.getClass(), resource.getIdElement()); + + var outcome = new MethodOutcome(lookup.id, false); + if (!lookup.isPresent()) { outcome.setCreated(true); } if (resource.fhirType().equals("SearchParameter")) { - this.resourceMatcher.addCustomParameter(BundleHelper.resourceToRuntimeSearchParam(resource)); + // fixme support adding SearchParameters + // this.resourceMatcher.addCustomParameter(BundleHelper.resourceToRuntimeSearchParam(resource)); } - resources.put(theId, resource); + lookup.put(resource); return outcome; } @@ -250,43 +258,44 @@ public C capabilities(Class resourceType, Map B transaction(B transaction, Map headers) { var version = transaction.getStructureFhirVersionEnum(); - @SuppressWarnings("unchecked") - var returnBundle = (B) newBundle(version); - BundleHelper.getEntry(transaction).forEach(e -> { - if (BundleHelper.isEntryRequestPut(version, e)) { - var outcome = this.update(BundleHelper.getEntryResource(version, e)); - var location = outcome.getId().getValue(); - BundleHelper.addEntry( - returnBundle, - BundleHelper.newEntryWithResponse( - version, BundleHelper.newResponseWithLocation(version, location))); - } else if (BundleHelper.isEntryRequestPost(version, e)) { - var outcome = this.create(BundleHelper.getEntryResource(version, e)); - var location = outcome.getId().getValue(); - BundleHelper.addEntry( - returnBundle, - BundleHelper.newEntryWithResponse( - version, BundleHelper.newResponseWithLocation(version, location))); - } else if (BundleHelper.isEntryRequestDelete(version, e)) { - if (BundleHelper.getEntryRequestId(version, e).isPresent()) { - var resourceType = Canonicals.getResourceType( - ((BundleEntryComponent) e).getRequest().getUrl()); - var resourceClass = - this.context.getResourceDefinition(resourceType).getImplementingClass(); - var res = this.delete( - resourceClass, - BundleHelper.getEntryRequestId(version, e).get().withResourceType(resourceType)); - BundleHelper.addEntry(returnBundle, BundleHelper.newEntryWithResource(res.getResource())); - } else { - throw new ResourceNotFoundException("Trying to delete an entry without id"); - } - - } else { - throw new NotImplementedOperationException("Transaction stub only supports PUT, POST or DELETE"); - } - }); - - return returnBundle; + // @SuppressWarnings("unchecked") + // var returnBundle = (B) newBundle(version); + // BundleHelper.getEntry(transaction).forEach(e -> { + // if (BundleHelper.isEntryRequestPut(version, e)) { + // var outcome = this.update(BundleHelper.getEntryResource(version, e)); + // var location = outcome.getId().getValue(); + // BundleHelper.addEntry( + // returnBundle, + // BundleHelper.newEntryWithResponse( + // version, BundleHelper.newResponseWithLocation(version, location))); + // } else if (BundleHelper.isEntryRequestPost(version, e)) { + // var outcome = this.create(BundleHelper.getEntryResource(version, e)); + // var location = outcome.getId().getValue(); + // BundleHelper.addEntry( + // returnBundle, + // BundleHelper.newEntryWithResponse( + // version, BundleHelper.newResponseWithLocation(version, location))); + // } else if (BundleHelper.isEntryRequestDelete(version, e)) { + // if (BundleHelper.getEntryRequestId(version, e).isPresent()) { + // var resourceType = Canonicals.getResourceType( + // ((BundleEntryComponent) e).getRequest().getUrl()); + // var resourceClass = + // this.context.getResourceDefinition(resourceType).getImplementingClass(); + // var res = this.delete( + // resourceClass, + // BundleHelper.getEntryRequestId(version, e).get().withResourceType(resourceType)); + // BundleHelper.addEntry(returnBundle, BundleHelper.newEntryWithResource(res.getResource())); + // } else { + // throw new ResourceNotFoundException("Trying to delete an entry without id"); + // } + // + // } else { + // throw new NotImplementedOperationException("Transaction stub only supports PUT, POST or DELETE"); + // } + // }); + // + // return returnBundle; + return null; } @Override @@ -323,4 +332,10 @@ public B h public @Nonnull FhirContext fhirContext() { return this.context; } + + @VisibleForTesting + @Nonnull + public Map getResourceMapForType(String resourceTypeName) { + return resourceMap.computeIfAbsent(resourceTypeName, x -> new HashMap<>()); + } } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java index 6d69f34be6e4..5e345be26fd7 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java @@ -6,9 +6,7 @@ import ca.uhn.fhir.rest.param.DateRangeParam; import ca.uhn.fhir.rest.param.TokenParam; import ca.uhn.fhir.util.FhirTerser; -import org.apache.commons.lang3.NotImplementedException; -import org.hl7.fhir.dstu3.model.Period; -import org.hl7.fhir.dstu3.model.Timing; +import jakarta.annotation.Nonnull; import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.ICompositeType; import org.hl7.fhir.instance.model.api.IPrimitiveType; @@ -17,7 +15,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import javax.annotation.Nonnull; public class MultiVersionResourceMatcher implements IResourceMatcher { private static final Map ourFhirPathCache = new ConcurrentHashMap<>(); @@ -57,14 +54,15 @@ public Map getCustomParameters() { @Override public DateRangeParam getDateRange(ICompositeType type) { - if (type instanceof Period) { - return new DateRangeParam(((Period) type).getStart(), ((Period) type).getEnd()); - } else if (type instanceof Timing) { - throw new NotImplementedException("Timing resolution has not yet been implemented"); - } else { - throw new UnsupportedOperationException("Expected element of type Period or Timing, found " - + type.getClass().getSimpleName()); - } + // fixme implement + // if (type instanceof Period) { + // return new DateRangeParam(((Period) type).getStart(), ((Period) type).getEnd()); + // } else if (type instanceof Timing) { + // throw new NotImplementedException("Timing resolution has not yet been implemented"); + // } else { + throw new UnsupportedOperationException("Expected element of type Period or Timing, found " + + type.getClass().getSimpleName()); + // } } @Override diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java index 9bc4109f1c7a..4d045408e718 100644 --- a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -80,7 +80,7 @@ default void testCreate_update_readById_verifyUpdatedContents() { // update with different birthdate var updatedPatient = b.buildPatient(b.withId(patientId), b.withBirthdate(updatedBirthdate)); var updateOutcome = repository.update(updatedPatient); - assertThat(updateOutcome.getId().toVersionless()).isEqualTo(patientId); + assertThat(updateOutcome.getId().toVersionless().getValueAsString()).isEqualTo(patientId.getValueAsString()); assertThat(updateOutcome.getCreated()).isFalse(); // read From 88936899d26322bc87425acfc9a51197d6ef9f21 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 4 Jul 2025 17:49:53 -0400 Subject: [PATCH 05/65] Test patch --- .../repository/InMemoryFhirRepository.java | 13 +++- .../repository/InMemoryRepositoryTest.java | 6 ++ .../jpa/repository/HapiFhirRepository.java | 5 +- .../uhn/fhir/repository/IRepositoryTest.java | 61 ++++++++++++++++--- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java index f33a7e3132ac..1e322b2f8d5c 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java @@ -90,6 +90,15 @@ public void put(T theResource) { } } + ResourceLookup lookupResource(IIdType theId) { + Validate.notNull(theId, "Id must not be null"); + Validate.notNull(theId.getResourceType(), "Resource type must not be null"); + + Map resources = getResourceMapForType(theId.getResourceType()); + + return new ResourceLookup(resources, new IdDt(theId)); + } + ResourceLookup lookupResource(Class theResourceType, IIdType theId) { Validate.notNull(theResourceType, "Resource type must not be null"); Validate.notNull(theId, "Id must not be null"); @@ -105,9 +114,7 @@ ResourceLookup lookupResource(Class theResourceType, II "Resource type mismatch: resource is " + resourceTypeName + " but id type is " + idResourceType); } - Map resources = getResourceMapForType(unqualifiedVersionless.getResourceType()); - - return new ResourceLookup(resources, new IdDt(unqualifiedVersionless)); + return lookupResource(unqualifiedVersionless); } @Override diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java index e7f712284c5f..c589b31346a4 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java @@ -10,4 +10,10 @@ public class InMemoryRepositoryTest implements IRepositoryTest { public RepositoryTestSupport getRepositoryTestSupport() { return new RepositoryTestSupport(myRepository); } + + @Override + public boolean isPatchSupported() { + return false; + } + } diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java index 6cd89f06f407..951c1aac155f 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java @@ -28,6 +28,7 @@ import ca.uhn.fhir.repository.IRepository; import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.api.PatchTypeEnum; import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.api.server.IBundleProvider; import ca.uhn.fhir.rest.api.server.RequestDetails; @@ -101,10 +102,10 @@ public MethodOutcome patch( .setAction(RestOperationTypeEnum.PATCH) .addHeaders(theHeaders) .create(); - // TODO update FHIR patchType once FHIRPATCH bug has been fixed + return myDaoRegistry .getResourceDao(theId.getResourceType()) - .patch(theId, null, null, null, thePatchParameters, details); + .patch(theId, null, PatchTypeEnum.FHIR_PATCH_JSON, null, thePatchParameters, details); } @Override diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java index 4d045408e718..a1d8d21194e3 100644 --- a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -9,14 +9,21 @@ import ca.uhn.fhir.test.utilities.ITestDataBuilder; import ca.uhn.fhir.test.utilities.RepositoryTestDataBuilder; import ca.uhn.fhir.util.FhirTerser; +import ca.uhn.fhir.util.ParametersUtil; +import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.DateType; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; +import static ca.uhn.fhir.util.ParametersUtil.addParameterToParameters; +import static ca.uhn.fhir.util.ParametersUtil.addPart; +import static ca.uhn.fhir.util.ParametersUtil.addPartCode; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -25,12 +32,14 @@ }) public interface IRepositoryTest { Logger ourLog = LoggerFactory.getLogger(IRepositoryTest.class); + String BIRTHDATE1 = "1970-02-14"; + String BIRTHDATE2 = "1975-01-01"; @Test default void testCreate_readById_contentsPersisted() { // given var b = getTestDataBuilder(); - var patient = b.buildPatient(b.withBirthdate("1970-02-14")); + var patient = b.buildPatient(b.withBirthdate(BIRTHDATE1)); IRepository repository = getRepository(); // when @@ -44,14 +53,14 @@ default void testCreate_readById_contentsPersisted() { .isNotNull() .extracting(p -> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) .as("resource body read matches persisted value") - .isEqualTo("1970-02-14"); + .isEqualTo(BIRTHDATE1); } @Test default void testCreateClientAssignedId_readBySameId_findsResource() { // given var b = getTestDataBuilder(); - var patient = b.buildPatient(b.withId("pat123"), b.withBirthdate("1970-02-14")); + var patient = b.buildPatient(b.withId("pat123"), b.withBirthdate(BIRTHDATE1)); IRepository repository = getRepository(); // when @@ -60,17 +69,15 @@ default void testCreateClientAssignedId_readBySameId_findsResource() { // then assertThat(read).isNotNull(); - assertThat(getTerser().getSinglePrimitiveValueOrNull(read, "birthDate")).isEqualTo("1970-02-14"); + assertThat(getTerser().getSinglePrimitiveValueOrNull(read, "birthDate")).isEqualTo(BIRTHDATE1); assertThat(read.getIdElement().getIdPart()).isEqualTo("pat123"); } @Test default void testCreate_update_readById_verifyUpdatedContents() { // given - String initialBirthdate = "1970-02-14"; - String updatedBirthdate = "1980-03-15"; var b = getTestDataBuilder(); - var patient = b.buildPatient(b.withBirthdate(initialBirthdate)); + var patient = b.buildPatient(b.withBirthdate(BIRTHDATE1)); IRepository repository = getRepository(); // when - create @@ -78,7 +85,7 @@ default void testCreate_update_readById_verifyUpdatedContents() { IIdType patientId = createOutcome.getId().toVersionless(); // update with different birthdate - var updatedPatient = b.buildPatient(b.withId(patientId), b.withBirthdate(updatedBirthdate)); + var updatedPatient = b.buildPatient(b.withId(patientId), b.withBirthdate(BIRTHDATE2)); var updateOutcome = repository.update(updatedPatient); assertThat(updateOutcome.getId().toVersionless().getValueAsString()).isEqualTo(patientId.getValueAsString()); assertThat(updateOutcome.getCreated()).isFalse(); @@ -91,14 +98,14 @@ default void testCreate_update_readById_verifyUpdatedContents() { .isNotNull() .extracting(p -> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) .as("resource body read matches updated value") - .isEqualTo(updatedBirthdate); + .isEqualTo(BIRTHDATE2); } @Test default void testCreate_delete_readById_throwsException() { // given a patient resource var b = getTestDataBuilder(); - var patient = b.buildPatient(b.withBirthdate("1970-02-14")); + var patient = b.buildPatient(b.withBirthdate(BIRTHDATE1)); IRepository repository = getRepository(); MethodOutcome createOutcome = repository.create(patient); IIdType patientId = createOutcome.getId().toVersionless(); @@ -127,6 +134,40 @@ default void testDelete_noCreate_returnsOutcome() { assertThat(outcome).isNotNull(); } + default boolean isPatchSupported() { + // todo this should really come from the repository capabilities + return true; + } + + @Test + @EnabledIf("isPatchSupported") + default void testPatch_changesValue() { + // given + var repository = getRepository(); + var fhirContext = getRepository().fhirContext(); + IBaseParameters parameters = ParametersUtil.newInstance(fhirContext); + var operation = addParameterToParameters(fhirContext, parameters, "operation"); + addPartCode(fhirContext, operation, "type", "replace"); + addPartCode(fhirContext, operation, "path", "Patient.birthDate"); + addPart(fhirContext, operation, "value", new DateType(BIRTHDATE2)); + + var b = getTestDataBuilder(); + var patient = b.buildPatient(b.withBirthdate(BIRTHDATE1)); + MethodOutcome createOutcome = repository.create(patient); + IIdType patientId = createOutcome.getId().toVersionless(); + + // when + repository.patch(patientId, parameters); + + // then + IBaseResource read = repository.read(patient.getClass(), patientId); + assertThat(read) + .isNotNull() + .extracting(p -> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) + .as("resource body read matches updated value") + .isEqualTo(BIRTHDATE2); + } + /** Implementors of this test template must provide a RepositoryTestSupport instance */ RepositoryTestSupport getRepositoryTestSupport(); From 9b84d6e165007706c8fcc4047e31a356bf28892b Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Mon, 7 Jul 2025 15:28:17 -0400 Subject: [PATCH 06/65] remove redundant not-implemented stubs. --- .../ca/uhn/fhir/repository/IRepository.java | 12 +- .../ca/uhn/fhir/repository/Repositories.java | 11 ++ .../{ => impl}/InMemoryFhirRepository.java | 157 +++++++----------- .../ca/uhn/fhir/repository/package-info.java | 6 + 4 files changed, 89 insertions(+), 97 deletions(-) create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java rename hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/{ => impl}/InMemoryFhirRepository.java (82%) create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/package-info.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java index 698a57419b86..8c9e49f05dba 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java @@ -501,8 +501,10 @@ default R invoke( - Class resourceType, String name, P parameters, Class returnType, Map headers); + default R invoke( + Class resourceType, String name, P parameters, Class returnType, Map headers) { + return throwNotImplementedOperationException("type-level invoke is not supported by this repository"); + } /** * Invokes a type-level operation on this repository @@ -573,8 +575,10 @@ default * @param headers headers for this request, typically key-value pairs of HTTP headers * @return the results of the operation */ - R invoke( - I id, String name, P parameters, Class returnType, Map headers); + default R invoke( + I id, String name, P parameters, Class returnType, Map headers) { + return throwNotImplementedOperationException("instance-level invoke is not supported by this repository"); + } /** * Invokes an instance-level operation on this repository diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java new file mode 100644 index 000000000000..c2e671afd585 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java @@ -0,0 +1,11 @@ +package ca.uhn.fhir.repository; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.repository.impl.InMemoryFhirRepository; + +public class Repositories { + IRepository emptyInMemoryRepository(FhirContext theFhirContext) { + return InMemoryFhirRepository.emptyRepository(theFhirContext); + } + +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java similarity index 82% rename from hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java rename to hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java index 1e322b2f8d5c..cd49b68e8681 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java @@ -1,10 +1,9 @@ -package ca.uhn.fhir.repository; +package ca.uhn.fhir.repository.impl; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.model.api.IQueryParameterType; import ca.uhn.fhir.model.primitive.IdDt; -import ca.uhn.fhir.repository.matcher.IResourceMatcher; -import ca.uhn.fhir.repository.matcher.MultiVersionResourceMatcher; +import ca.uhn.fhir.repository.IRepository; import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.server.exceptions.NotImplementedOperationException; @@ -40,9 +39,10 @@ */ public class InMemoryFhirRepository implements IRepository { + // fixme add search with tests + // fixme add sketch of extended operations private final Map> resourceMap; private final FhirContext context; - private final IResourceMatcher resourceMatcher; public static InMemoryFhirRepository emptyRepository(@Nonnull FhirContext theFhirContext) { return new InMemoryFhirRepository(theFhirContext, new HashMap<>()); @@ -63,59 +63,13 @@ public static InMemoryFhirRepository fromBundleContents(FhirContext theFhirConte @Nonnull FhirContext theContext, @Nonnull Map> theContents) { context = theContext; resourceMap = theContents; - resourceMatcher = new MultiVersionResourceMatcher(context); } - record ResourceLookup(Map resources, IIdType id) { - @Nonnull - IBaseResource getResourceOrThrow404() { - var resource = resources.get(id); - - if (resource == null) { - throw new ResourceNotFoundException("Resource not found with id " + id); - } - return resource; - } - - void remove() { - resources.remove(id); - } - - boolean isPresent() { - return resources.containsKey(id); - } - - public void put(T theResource) { - resources.put(id, theResource); - } - } - - ResourceLookup lookupResource(IIdType theId) { - Validate.notNull(theId, "Id must not be null"); - Validate.notNull(theId.getResourceType(), "Resource type must not be null"); - - Map resources = getResourceMapForType(theId.getResourceType()); - - return new ResourceLookup(resources, new IdDt(theId)); + @Override + public @Nonnull FhirContext fhirContext() { + return this.context; } - ResourceLookup lookupResource(Class theResourceType, IIdType theId) { - Validate.notNull(theResourceType, "Resource type must not be null"); - Validate.notNull(theId, "Id must not be null"); - - String resourceTypeName = fhirContext().getResourceType(theResourceType); - - IIdType unqualifiedVersionless = theId.toUnqualifiedVersionless(); - String idResourceType = unqualifiedVersionless.getResourceType(); - if (idResourceType == null) { - unqualifiedVersionless = unqualifiedVersionless.withResourceType(resourceTypeName); - } else if (!idResourceType.equals(resourceTypeName)) { - throw new IllegalArgumentException( - "Resource type mismatch: resource is " + resourceTypeName + " but id type is " + idResourceType); - } - - return lookupResource(unqualifiedVersionless); - } @Override @SuppressWarnings("unchecked") @@ -251,16 +205,6 @@ public B search( return (B) builder.getBundle(); } - @Override - public B link(Class bundleType, String url, Map headers) { - throw new NotImplementedOperationException("Paging is not currently supported"); - } - - @Override - public C capabilities(Class resourceType, Map headers) { - throw new NotImplementedOperationException("The capabilities interaction is not currently supported"); - } - @Override public B transaction(B transaction, Map headers) { var version = transaction.getStructureFhirVersionEnum(); @@ -305,44 +249,71 @@ public B transaction(B transaction, Map return null; } - @Override - public R invoke( - Class resourceType, String name, P parameters, Class returnType, Map headers) { - return null; + /** + * The map of resources for each resource type. + */ + @VisibleForTesting + @Nonnull + public Map getResourceMapForType(String resourceTypeName) { + return resourceMap.computeIfAbsent(resourceTypeName, x -> new HashMap<>()); } - @Override - public R invoke( - I id, String name, P parameters, Class returnType, Map headers) { - return null; - } + /** + * Abstract "pointer" to a resource id in the repository. + * @param resources the map of resources for a specific type + * @param id the id of the resource to look up + */ + private record ResourceLookup(Map resources, IIdType id) { + @Nonnull + IBaseResource getResourceOrThrow404() { + var resource = resources.get(id); - @Override - public B history( - P parameters, Class returnType, Map headers) { - throw new NotImplementedOperationException("The history interaction is not currently supported"); - } + if (resource == null) { + throw new ResourceNotFoundException("Resource not found with id " + id); + } + return resource; + } - @Override - public B history( - Class resourceType, P parameters, Class returnType, Map headers) { - throw new NotImplementedOperationException("The history interaction is not currently supported"); - } + void remove() { + resources.remove(id); + } - @Override - public B history( - I id, P parameters, Class returnType, Map headers) { - throw new NotImplementedOperationException("The history interaction is not currently supported"); + boolean isPresent() { + return resources.containsKey(id); + } + + public void put(T theResource) { + resources.put(id, theResource); + } } - @Override - public @Nonnull FhirContext fhirContext() { - return this.context; + + private ResourceLookup lookupResource(IIdType theId) { + Validate.notNull(theId, "Id must not be null"); + Validate.notNull(theId.getResourceType(), "Resource type must not be null"); + + Map resources = getResourceMapForType(theId.getResourceType()); + + return new ResourceLookup(resources, new IdDt(theId)); } - @VisibleForTesting - @Nonnull - public Map getResourceMapForType(String resourceTypeName) { - return resourceMap.computeIfAbsent(resourceTypeName, x -> new HashMap<>()); + private ResourceLookup lookupResource(Class theResourceType, IIdType theId) { + Validate.notNull(theResourceType, "Resource type must not be null"); + Validate.notNull(theId, "Id must not be null"); + + String resourceTypeName = fhirContext().getResourceType(theResourceType); + + IIdType unqualifiedVersionless = theId.toUnqualifiedVersionless(); + String idResourceType = unqualifiedVersionless.getResourceType(); + if (idResourceType == null) { + unqualifiedVersionless = unqualifiedVersionless.withResourceType(resourceTypeName); + } else if (!idResourceType.equals(resourceTypeName)) { + throw new IllegalArgumentException( + "Resource type mismatch: resource is " + resourceTypeName + " but id type is " + idResourceType); + } + + return lookupResource(unqualifiedVersionless); } + + } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/package-info.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/package-info.java new file mode 100644 index 000000000000..035b6bf56264 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/package-info.java @@ -0,0 +1,6 @@ +/** + * This package contains the abstract repository interface and a simple implementation. + * The InMemoryRepository is a simple in-memory implementation suitable for testing. + * Use the Repositoiries class to create an empty in-memory repository. + */ +package ca.uhn.fhir.repository; From daa231d12d8cad883e5121fdef0e348b67ce6765 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Mon, 7 Jul 2025 16:22:52 -0400 Subject: [PATCH 07/65] InMemory test --- .../uhn/fhir/repository/{ => impl}/InMemoryRepositoryTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/{ => impl}/InMemoryRepositoryTest.java (84%) diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/InMemoryRepositoryTest.java similarity index 84% rename from hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java rename to hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/InMemoryRepositoryTest.java index c589b31346a4..75998b44af0c 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/InMemoryRepositoryTest.java +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/InMemoryRepositoryTest.java @@ -1,6 +1,7 @@ -package ca.uhn.fhir.repository; +package ca.uhn.fhir.repository.impl; import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.repository.IRepositoryTest; public class InMemoryRepositoryTest implements IRepositoryTest { FhirContext myFhirContext = FhirContext.forR4(); From 28bb118ff919334ec35a9aa6d4933623e89f35c9 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Mon, 7 Jul 2025 16:28:19 -0400 Subject: [PATCH 08/65] Spotless --- .../src/main/java/ca/uhn/fhir/repository/Repositories.java | 1 - .../uhn/fhir/repository/impl/InMemoryFhirRepository.java | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java index c2e671afd585..40d66c0e2989 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java @@ -7,5 +7,4 @@ public class Repositories { IRepository emptyInMemoryRepository(FhirContext theFhirContext) { return InMemoryFhirRepository.emptyRepository(theFhirContext); } - } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java index cd49b68e8681..91b98ba1ed7c 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java @@ -16,7 +16,6 @@ import jakarta.annotation.Nonnull; import org.apache.commons.lang3.Validate; import org.hl7.fhir.instance.model.api.IBaseBundle; -import org.hl7.fhir.instance.model.api.IBaseConformance; import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; @@ -70,7 +69,6 @@ public static InMemoryFhirRepository fromBundleContents(FhirContext theFhirConte return this.context; } - @Override @SuppressWarnings("unchecked") public T read( @@ -287,7 +285,6 @@ public void put(T theResource) { } } - private ResourceLookup lookupResource(IIdType theId) { Validate.notNull(theId, "Id must not be null"); Validate.notNull(theId.getResourceType(), "Resource type must not be null"); @@ -309,11 +306,9 @@ private ResourceLookup lookupResource(Class theResource unqualifiedVersionless = unqualifiedVersionless.withResourceType(resourceTypeName); } else if (!idResourceType.equals(resourceTypeName)) { throw new IllegalArgumentException( - "Resource type mismatch: resource is " + resourceTypeName + " but id type is " + idResourceType); + "Resource type mismatch: resource is " + resourceTypeName + " but id type is " + idResourceType); } return lookupResource(unqualifiedVersionless); } - - } From fb53ff52bdb3e5adc431642b3da09e4d8997aba6 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Mon, 7 Jul 2025 16:29:02 -0400 Subject: [PATCH 09/65] Spotless --- .../java/ca/uhn/fhir/repository/IRepositoryTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java index a1d8d21194e3..29e1cba0e925 100644 --- a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -142,7 +142,7 @@ default boolean isPatchSupported() { @Test @EnabledIf("isPatchSupported") default void testPatch_changesValue() { - // given + // given var repository = getRepository(); var fhirContext = getRepository().fhirContext(); IBaseParameters parameters = ParametersUtil.newInstance(fhirContext); @@ -159,13 +159,13 @@ default void testPatch_changesValue() { // when repository.patch(patientId, parameters); - // then + // then IBaseResource read = repository.read(patient.getClass(), patientId); assertThat(read) - .isNotNull() - .extracting(p -> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) - .as("resource body read matches updated value") - .isEqualTo(BIRTHDATE2); + .isNotNull() + .extracting(p -> getTerser().getSinglePrimitiveValueOrNull(p, "birthDate")) + .as("resource body read matches updated value") + .isEqualTo(BIRTHDATE2); } /** Implementors of this test template must provide a RepositoryTestSupport instance */ From e25f1159dd3a6577a1dda0cac57346256fbb8178 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Mon, 7 Jul 2025 17:05:06 -0400 Subject: [PATCH 10/65] start GenericClientRepository --- .../impl/GenericClientRepository.java | 253 ++++++++++++++++++ .../impl/GenericClientRepositoryTest.java | 47 ++++ 2 files changed, 300 insertions(+) create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java create mode 100644 hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java new file mode 100644 index 000000000000..442338e1b055 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java @@ -0,0 +1,253 @@ +package ca.uhn.fhir.repository.impl; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.repository.IRepository; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.gclient.IClientExecutable; +import ca.uhn.fhir.rest.gclient.IHistoryTyped; +import ca.uhn.fhir.util.ParametersUtil; +import com.google.common.collect.Multimap; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.instance.model.api.IBaseConformance; +import org.hl7.fhir.instance.model.api.IBaseParameters; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.instance.model.api.IPrimitiveType; + +public class GenericClientRepository implements IRepository { + + public GenericClientRepository(IGenericClient client) { + this.client = client; + } + + private IGenericClient client; + + protected IGenericClient getClient() { + return this.client; + } + + @Override + public T read( + Class resourceType, I id, Map headers) { + var op = this.client.read().resource(resourceType).withId(id); + return this.addHeaders(op, headers).execute(); + } + + @Override + public MethodOutcome create(T resource, Map headers) { + var op = this.client.create().resource(resource); + return this.addHeaders(op, headers).execute(); + } + + @Override + public MethodOutcome patch( + I id, P patchParameters, Map headers) { + var op = this.client.patch().withFhirPatch(patchParameters).withId(id); + return this.addHeaders(op, headers).execute(); + } + + @Override + public MethodOutcome update(T resource, Map headers) { + var op = this.client.update().resource(resource).withId(resource.getIdElement()); + return this.addHeaders(op, headers).execute(); + } + + @Override + public MethodOutcome delete( + Class resourceType, I id, Map headers) { + var op = this.client.delete().resourceById(id); + return this.addHeaders(op, headers).execute(); + } + + @Override + public B search( + Class bundleType, + Class resourceType, + Multimap> searchParameters, + Map headers) { + var params = new HashMap>(); + if (searchParameters != null) { + for (var key : searchParameters.keySet()) { + var flattenLists = + searchParameters.get(key).stream().flatMap(List::stream).toList(); + + params.put(key, flattenLists); + } + searchParameters.entries().forEach(p -> params.put(p.getKey(), p.getValue())); + } + return search(bundleType, resourceType, params, Collections.emptyMap()); + } + + @Override + public B search( + Class bundleType, + Class resourceType, + Map> searchParameters, + Map headers) { + var op = this.client.search().forResource(resourceType).returnBundle(bundleType); + if (searchParameters != null) { + op = op.where(searchParameters); + } + + if (headers != null) { + for (var entry : headers.entrySet()) { + op = op.withAdditionalHeader(entry.getKey(), entry.getValue()); + } + } + + return this.addHeaders(op, headers).execute(); + } + + @Override + public C capabilities(Class resourceType, Map headers) { + var op = this.client.capabilities().ofType(resourceType); + return this.addHeaders(op, headers).execute(); + } + + @Override + public B transaction(B transaction, Map headers) { + var op = this.client.transaction().withBundle(transaction); + return this.addHeaders(op, headers).execute(); + } + + @Override + public B link(Class bundleType, String url, Map headers) { + var op = this.client.loadPage().byUrl(url).andReturnBundle(bundleType); + return this.addHeaders(op, headers).execute(); + } + + @Override + public R invoke( + String name, P parameters, Class returnType, Map headers) { + var op = this.client + .operation() + .onServer() + .named(name) + .withParameters(parameters) + .returnResourceType(returnType); + return this.addHeaders(op, headers).execute(); + } + + @Override + public

MethodOutcome invoke(String name, P parameters, Map headers) { + var op = this.client + .operation() + .onServer() + .named(name) + .withParameters(parameters) + .returnMethodOutcome(); + return this.addHeaders(op, headers).execute(); + } + + @Override + public R invoke( + Class resourceType, String name, P parameters, Class returnType, Map headers) { + var op = this.client + .operation() + .onType(resourceType) + .named(name) + .withParameters(parameters) + .returnResourceType(returnType); + return this.addHeaders(op, headers).execute(); + } + + @Override + public

MethodOutcome invoke( + Class resourceType, String name, P parameters, Map headers) { + var op = this.client + .operation() + .onType(resourceType) + .named(name) + .withParameters(parameters) + .returnMethodOutcome(); + return this.addHeaders(op, headers).execute(); + } + + @Override + public R invoke( + I id, String name, P parameters, Class returnType, Map headers) { + var op = this.client + .operation() + .onInstance(id) + .named(name) + .withParameters(parameters) + .returnResourceType(returnType); + return this.addHeaders(op, headers).execute(); + } + + @Override + public

MethodOutcome invoke( + I id, String name, P parameters, Map headers) { + var op = this.client + .operation() + .onInstance(id) + .named(name) + .withParameters(parameters) + .returnMethodOutcome(); + return this.addHeaders(op, headers).execute(); + } + + @Override + public B history( + P parameters, Class returnType, Map headers) { + var op = this.client.history().onServer().returnBundle(returnType); + this.addHistoryParams(null, parameters); + return this.addHeaders(op, headers).execute(); + } + + @Override + public B history( + Class resourceType, P parameters, Class returnType, Map headers) { + var op = this.client.history().onType(resourceType).returnBundle(returnType); + this.addHistoryParams(null, parameters); + return this.addHeaders(op, headers).execute(); + } + + @Override + public B history( + I id, P parameters, Class returnType, Map headers) { + var op = this.client.history().onInstance(id).returnBundle(returnType); + this.addHistoryParams(null, parameters); + return this.addHeaders(op, headers).execute(); + } + + @Override + public FhirContext fhirContext() { + return this.getClient().getFhirContext(); + } + + @SuppressWarnings("unchecked") + protected void addHistoryParams( + IHistoryTyped operation, P parameters) { + + var ctx = this.client.getFhirContext(); + var count = ParametersUtil.getNamedParameterValuesAsInteger(ctx, parameters, "_count"); + if (count != null && !count.isEmpty()) { + operation.count(count.get(0)); + } + + // TODO: Figure out how to handle date ranges for the _at parameter + + var since = ParametersUtil.getNamedParameter(ctx, parameters, "_since"); + if (since.isPresent()) { + operation.since((IPrimitiveType) since.get()); + } + } + + protected > T addHeaders(T op, Map headers) { + if (headers != null) { + for (var entry : headers.entrySet()) { + op = op.withAdditionalHeader(entry.getKey(), entry.getValue()); + } + } + + return op; + } +} diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java new file mode 100644 index 000000000000..81ac555ed4c5 --- /dev/null +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java @@ -0,0 +1,47 @@ +package ca.uhn.fhir.repository.impl; + +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Map; + +import static ca.uhn.fhir.rest.api.Constants.HEADER_IF_NONE_MATCH; +import static org.mockito.ArgumentMatchers.any; + +@MockitoSettings( + strictness = Strictness.WARN +) +class GenericClientRepositoryTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + IGenericClient myGenericClient; + + @InjectMocks + GenericClientRepository myGenericClientRepository; + + @Test + void testCreate() { + // given + IBaseResource mockResource = org.mockito.Mockito.mock(IBaseResource.class); + MethodOutcome stubOutcome = new MethodOutcome(); + Mockito.when(myGenericClient.create().resource(mockResource).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + Mockito.when(myGenericClient.create().resource(mockResource).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(stubOutcome); + + // when + MethodOutcome outcome = myGenericClientRepository.create(mockResource, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then check the stubs call on our myGenericClient + Mockito.verify(myGenericClient.create().resource(mockResource).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + + + } + +} From 268a2e8c1f3a01ce3a618f93e7bf174cbbe980b4 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Mon, 7 Jul 2025 19:08:08 -0400 Subject: [PATCH 11/65] test GenericClientRepository --- .../ca/uhn/fhir/repository/Repositories.java | 8 +- .../impl/GenericClientRepository.java | 224 ++++++----- .../impl/GenericClientRepositoryTest.java | 47 --- .../impl/GenericClientRepositoryTest.java | 356 ++++++++++++++++++ 4 files changed, 473 insertions(+), 162 deletions(-) delete mode 100644 hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java create mode 100644 hapi-fhir-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java index 40d66c0e2989..fabaf0129a18 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java @@ -1,10 +1,16 @@ package ca.uhn.fhir.repository; import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.repository.impl.GenericClientRepository; import ca.uhn.fhir.repository.impl.InMemoryFhirRepository; +import ca.uhn.fhir.rest.client.api.IGenericClient; public class Repositories { - IRepository emptyInMemoryRepository(FhirContext theFhirContext) { + public IRepository emptyInMemoryRepository(FhirContext theFhirContext) { return InMemoryFhirRepository.emptyRepository(theFhirContext); } + + public IRepository restClientRepository(IGenericClient theGenericClient) { + return new GenericClientRepository(theGenericClient); + } } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java index 442338e1b055..b6887cc24754 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java @@ -7,13 +7,12 @@ import ca.uhn.fhir.rest.client.api.IGenericClient; import ca.uhn.fhir.rest.gclient.IClientExecutable; import ca.uhn.fhir.rest.gclient.IHistoryTyped; +import ca.uhn.fhir.rest.gclient.IQuery; +import ca.uhn.fhir.rest.gclient.IUntypedQuery; import ca.uhn.fhir.util.ParametersUtil; import com.google.common.collect.Multimap; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import com.google.common.collect.Multimaps; +import jakarta.annotation.Nonnull; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseConformance; import org.hl7.fhir.instance.model.api.IBaseParameters; @@ -21,204 +20,201 @@ import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IPrimitiveType; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * Adaptor from IGenericClient to IRepository. + * Based on the clinical reasoning org.opencds.cqf.fhir.utility.repository.RestRepository. + */ public class GenericClientRepository implements IRepository { - public GenericClientRepository(IGenericClient client) { - this.client = client; + public GenericClientRepository(IGenericClient theGenericClient) { + this.myGenericClient = theGenericClient; } - private IGenericClient client; + private final IGenericClient myGenericClient; protected IGenericClient getClient() { - return this.client; + return this.myGenericClient; } @Override public T read( - Class resourceType, I id, Map headers) { - var op = this.client.read().resource(resourceType).withId(id); - return this.addHeaders(op, headers).execute(); + Class theResourceType, I theId, Map theHeaders) { + var op = this.myGenericClient.read().resource(theResourceType).withId(theId); + return this.addHeaders(op, theHeaders).execute(); } @Override - public MethodOutcome create(T resource, Map headers) { - var op = this.client.create().resource(resource); - return this.addHeaders(op, headers).execute(); + public MethodOutcome create(T theResource, Map theHeaders) { + var op = this.myGenericClient.create().resource(theResource); + return this.addHeaders(op, theHeaders).execute(); } @Override public MethodOutcome patch( - I id, P patchParameters, Map headers) { - var op = this.client.patch().withFhirPatch(patchParameters).withId(id); - return this.addHeaders(op, headers).execute(); + I theId, P thePatchparameters, Map theHeaders) { + var op = this.myGenericClient.patch().withFhirPatch(thePatchparameters).withId(theId); + return this.addHeaders(op, theHeaders).execute(); } @Override - public MethodOutcome update(T resource, Map headers) { - var op = this.client.update().resource(resource).withId(resource.getIdElement()); - return this.addHeaders(op, headers).execute(); + public MethodOutcome update(T theResource, Map theHeaders) { + var op = this.myGenericClient.update().resource(theResource).withId(theResource.getIdElement()); + return this.addHeaders(op, theHeaders).execute(); } @Override public MethodOutcome delete( - Class resourceType, I id, Map headers) { - var op = this.client.delete().resourceById(id); - return this.addHeaders(op, headers).execute(); + Class theResourcetype, I theId, Map theHeaders) { + var op = this.myGenericClient.delete().resourceById(theId); + return this.addHeaders(op, theHeaders).execute(); } @Override public B search( - Class bundleType, - Class resourceType, - Multimap> searchParameters, - Map headers) { - var params = new HashMap>(); - if (searchParameters != null) { - for (var key : searchParameters.keySet()) { - var flattenLists = - searchParameters.get(key).stream().flatMap(List::stream).toList(); - - params.put(key, flattenLists); - } - searchParameters.entries().forEach(p -> params.put(p.getKey(), p.getValue())); - } - return search(bundleType, resourceType, params, Collections.emptyMap()); + Class theBundleType, + Class theSearchResourceType, + Multimap> theSearchParameters, + Map theHeaders) { + IUntypedQuery search = this.myGenericClient.search(); + IQuery iBaseBundleIQuery = search.forResource(theSearchResourceType); + var op = iBaseBundleIQuery.returnBundle(theBundleType); + if (theSearchParameters != null) { + theSearchParameters.entries().forEach(e-> + op.where(Map.of(e.getKey(), e.getValue()))); + } + + return this.addHeaders(op, theHeaders).execute(); + } @Override public B search( - Class bundleType, - Class resourceType, - Map> searchParameters, - Map headers) { - var op = this.client.search().forResource(resourceType).returnBundle(bundleType); - if (searchParameters != null) { - op = op.where(searchParameters); - } - - if (headers != null) { - for (var entry : headers.entrySet()) { - op = op.withAdditionalHeader(entry.getKey(), entry.getValue()); - } - } - - return this.addHeaders(op, headers).execute(); + Class theBundleType, + Class theResourceType, + Map> theSearchParameters, + Map theHeaders) { + return search(theBundleType, theResourceType, Multimaps.forMap(theSearchParameters), theHeaders); } @Override - public C capabilities(Class resourceType, Map headers) { - var op = this.client.capabilities().ofType(resourceType); - return this.addHeaders(op, headers).execute(); + public C capabilities(Class theResourceType, Map theHeaders) { + var op = this.myGenericClient.capabilities().ofType(theResourceType); + return this.addHeaders(op, theHeaders).execute(); } @Override - public B transaction(B transaction, Map headers) { - var op = this.client.transaction().withBundle(transaction); - return this.addHeaders(op, headers).execute(); + public B transaction(B theBundle, Map theHeaders) { + var op = this.myGenericClient.transaction().withBundle(theBundle); + return this.addHeaders(op, theHeaders).execute(); } @Override - public B link(Class bundleType, String url, Map headers) { - var op = this.client.loadPage().byUrl(url).andReturnBundle(bundleType); - return this.addHeaders(op, headers).execute(); + public B link(Class theBundleType, String url, Map theHeaders) { + var op = this.myGenericClient.loadPage().byUrl(url).andReturnBundle(theBundleType); + return this.addHeaders(op, theHeaders).execute(); } @Override public R invoke( - String name, P parameters, Class returnType, Map headers) { - var op = this.client + String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient .operation() .onServer() - .named(name) - .withParameters(parameters) - .returnResourceType(returnType); - return this.addHeaders(op, headers).execute(); + .named(theOperationName) + .withParameters(theParameters) + .returnResourceType(theReturnType); + return this.addHeaders(op, theHeaders).execute(); } @Override - public

MethodOutcome invoke(String name, P parameters, Map headers) { - var op = this.client + public

MethodOutcome invoke(String theOperationName, P theParameters, Map theHeaders) { + var op = this.myGenericClient .operation() .onServer() - .named(name) - .withParameters(parameters) + .named(theOperationName) + .withParameters(theParameters) .returnMethodOutcome(); - return this.addHeaders(op, headers).execute(); + return this.addHeaders(op, theHeaders).execute(); } @Override public R invoke( - Class resourceType, String name, P parameters, Class returnType, Map headers) { - var op = this.client + Class theResourceType, String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient .operation() - .onType(resourceType) - .named(name) - .withParameters(parameters) - .returnResourceType(returnType); - return this.addHeaders(op, headers).execute(); + .onType(theResourceType) + .named(theOperationName) + .withParameters(theParameters) + .returnResourceType(theReturnType); + return this.addHeaders(op, theHeaders).execute(); } @Override public

MethodOutcome invoke( - Class resourceType, String name, P parameters, Map headers) { - var op = this.client + Class theResourceType, String theOperationName, P parameters, Map theHeaders) { + var op = this.myGenericClient .operation() - .onType(resourceType) - .named(name) + .onType(theResourceType) + .named(theOperationName) .withParameters(parameters) .returnMethodOutcome(); - return this.addHeaders(op, headers).execute(); + return this.addHeaders(op, theHeaders).execute(); } @Override public R invoke( - I id, String name, P parameters, Class returnType, Map headers) { - var op = this.client + I theId, String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient .operation() - .onInstance(id) - .named(name) - .withParameters(parameters) - .returnResourceType(returnType); - return this.addHeaders(op, headers).execute(); + .onInstance(theId) + .named(theOperationName) + .withParameters(theParameters) + .returnResourceType(theReturnType); + return this.addHeaders(op, theHeaders).execute(); } @Override public

MethodOutcome invoke( - I id, String name, P parameters, Map headers) { - var op = this.client + I theResourceId, String theOperationName, P theParameters, Map theHeaders) { + var op = this.myGenericClient .operation() - .onInstance(id) - .named(name) - .withParameters(parameters) + .onInstance(theResourceId) + .named(theOperationName) + .withParameters(theParameters) .returnMethodOutcome(); - return this.addHeaders(op, headers).execute(); + return this.addHeaders(op, theHeaders).execute(); } @Override public B history( - P parameters, Class returnType, Map headers) { - var op = this.client.history().onServer().returnBundle(returnType); - this.addHistoryParams(null, parameters); - return this.addHeaders(op, headers).execute(); + P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient.history().onServer().returnBundle(theReturnType); + this.addHistoryParams(null, theParameters); + return this.addHeaders(op, theHeaders).execute(); } @Override public B history( - Class resourceType, P parameters, Class returnType, Map headers) { - var op = this.client.history().onType(resourceType).returnBundle(returnType); - this.addHistoryParams(null, parameters); - return this.addHeaders(op, headers).execute(); + Class theResourceType, P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient.history().onType(theResourceType).returnBundle(theReturnType); + this.addHistoryParams(op, theParameters); + return this.addHeaders(op, theHeaders).execute(); } @Override public B history( - I id, P parameters, Class returnType, Map headers) { - var op = this.client.history().onInstance(id).returnBundle(returnType); - this.addHistoryParams(null, parameters); - return this.addHeaders(op, headers).execute(); + I theResourceId, P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient.history().onInstance(theResourceId).returnBundle(theReturnType); + this.addHistoryParams(null, theParameters); + return this.addHeaders(op, theHeaders).execute(); } - @Override + @Nonnull + @Override public FhirContext fhirContext() { return this.getClient().getFhirContext(); } @@ -227,7 +223,7 @@ public FhirContext fhirContext() { protected void addHistoryParams( IHistoryTyped operation, P parameters) { - var ctx = this.client.getFhirContext(); + var ctx = this.myGenericClient.getFhirContext(); var count = ParametersUtil.getNamedParameterValuesAsInteger(ctx, parameters, "_count"); if (count != null && !count.isEmpty()) { operation.count(count.get(0)); diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java deleted file mode 100644 index 81ac555ed4c5..000000000000 --- a/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package ca.uhn.fhir.repository.impl; - -import ca.uhn.fhir.rest.api.MethodOutcome; -import ca.uhn.fhir.rest.client.api.IGenericClient; -import org.hl7.fhir.instance.model.api.IBaseResource; -import org.junit.jupiter.api.Test; -import org.mockito.Answers; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; - -import java.util.Map; - -import static ca.uhn.fhir.rest.api.Constants.HEADER_IF_NONE_MATCH; -import static org.mockito.ArgumentMatchers.any; - -@MockitoSettings( - strictness = Strictness.WARN -) -class GenericClientRepositoryTest { - - @Mock(answer = Answers.RETURNS_DEEP_STUBS) - IGenericClient myGenericClient; - - @InjectMocks - GenericClientRepository myGenericClientRepository; - - @Test - void testCreate() { - // given - IBaseResource mockResource = org.mockito.Mockito.mock(IBaseResource.class); - MethodOutcome stubOutcome = new MethodOutcome(); - Mockito.when(myGenericClient.create().resource(mockResource).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); - Mockito.when(myGenericClient.create().resource(mockResource).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(stubOutcome); - - // when - MethodOutcome outcome = myGenericClientRepository.create(mockResource, Map.of(HEADER_IF_NONE_MATCH, "abc123")); - - // then check the stubs call on our myGenericClient - Mockito.verify(myGenericClient.create().resource(mockResource).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); - - - } - -} diff --git a/hapi-fhir-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java b/hapi-fhir-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java new file mode 100644 index 000000000000..1e0252c292dd --- /dev/null +++ b/hapi-fhir-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/GenericClientRepositoryTest.java @@ -0,0 +1,356 @@ +package ca.uhn.fhir.repository.impl; + +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.gclient.IQuery; +import ca.uhn.fhir.rest.gclient.IUntypedQuery; +import ca.uhn.fhir.rest.param.DateParam; +import ca.uhn.fhir.rest.param.StringParam; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.instance.model.api.IBaseConformance; +import org.hl7.fhir.instance.model.api.IBaseParameters; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; +import java.util.Map; + +import static ca.uhn.fhir.rest.api.Constants.HEADER_IF_NONE_MATCH; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyMap; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@MockitoSettings( + strictness = Strictness.WARN +) +@SuppressWarnings("unchecked") +class GenericClientRepositoryTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + IGenericClient myGenericClient; + + @InjectMocks + GenericClientRepository myGenericClientRepository; + + @Test + void testCreate() { + // given + IBaseResource mockResource = mock(IBaseResource.class); + MethodOutcome stubOutcome = new MethodOutcome(); + when(myGenericClient.create().resource(mockResource).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.create().resource(mockResource).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(stubOutcome); + + // when + MethodOutcome outcome = myGenericClientRepository.create(mockResource, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then check the stubs call on our myGenericClient + verify(myGenericClient.create().resource(mockResource).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(stubOutcome, outcome); + } + + @Test + void testRead() { + // given + IIdType mockId = mock(IIdType.class); + IBaseResource mockResource = mock(IBaseResource.class); + Class resourceType = IBaseResource.class; + + when(myGenericClient.read().resource(resourceType).withId(mockId).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.read().resource(resourceType).withId(mockId).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(mockResource); + + // when + IBaseResource result = myGenericClientRepository.read(resourceType, mockId, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.read().resource(resourceType).withId(mockId).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(mockResource, result); + } + + @Test + void testUpdate() { + // given + IBaseResource mockResource = mock(IBaseResource.class); + IIdType mockId = mock(IIdType.class); + MethodOutcome stubOutcome = new MethodOutcome(); + + when(mockResource.getIdElement()).thenReturn(mockId); + when(myGenericClient.update().resource(mockResource).withId(mockId).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.update().resource(mockResource).withId(mockId).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(stubOutcome); + + // when + MethodOutcome outcome = myGenericClientRepository.update(mockResource, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.update().resource(mockResource).withId(mockId).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(stubOutcome, outcome); + } + + @Test + void testDelete() { + // given + IIdType mockId = mock(IIdType.class); + Class resourceType = IBaseResource.class; + MethodOutcome stubOutcome = new MethodOutcome(); + + when(myGenericClient.delete().resourceById(mockId).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.delete().resourceById(mockId).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(stubOutcome); + + // when + MethodOutcome outcome = myGenericClientRepository.delete(resourceType, mockId, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.delete().resourceById(mockId).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(stubOutcome, outcome); + } + + @Test + void testPatch() { + // given + IIdType mockId = mock(IIdType.class); + IBaseParameters mockParameters = mock(IBaseParameters.class); + MethodOutcome stubOutcome = new MethodOutcome(); + + when(myGenericClient.patch().withFhirPatch(mockParameters).withId(mockId).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.patch().withFhirPatch(mockParameters).withId(mockId).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(stubOutcome); + + // when + MethodOutcome outcome = myGenericClientRepository.patch(mockId, mockParameters, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.patch().withFhirPatch(mockParameters).withId(mockId).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(stubOutcome, outcome); + } + + @Test + void testSearchWithMultimap() { + // given + Bundle mockBundle = new Bundle(); + Multimap> searchParameters = ArrayListMultimap.create(); + List lowerBound = List.of(new DateParam("ge1970")); + searchParameters.put("birthdate", lowerBound); + List upperBound = List.of(new DateParam("lt1980")); + searchParameters.put("birthdate", upperBound); + + // Simplify the test by just verifying the result + // Use a more specific matcher for forResource to avoid ambiguity + IQuery queryMock = mock(IQuery.class); + when(myGenericClient.search().forResource(Patient.class).returnBundle(Bundle.class)).thenReturn(queryMock); + when(queryMock.withAdditionalHeader(any(), any())).thenReturn(queryMock); + when(queryMock.where(anyMap())).thenReturn(queryMock); + when(queryMock.execute()).thenReturn(mockBundle); + + // when + IBaseBundle result = myGenericClientRepository.search(Bundle.class, Patient.class, searchParameters, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + assertEquals(mockBundle, result); + // this is important. If there are ANDs in the search parameters, we need to ensure that the query is built correctly + verify(queryMock).where(Map.of("birthdate", lowerBound)); + verify(queryMock).where(Map.of("birthdate", upperBound)); + } + + @Test + void testSearchWithMap() { + // given + Bundle resultBundle = new Bundle(); + Map> searchParameters = Map.of("name", List.of(new StringParam("franklin"))); + + IUntypedQuery searchMock = mock(IUntypedQuery.class); + IQuery queryMock = mock(IQuery.class); + when(myGenericClient.search()).thenReturn(searchMock); + when(searchMock.forResource(Patient.class)).thenReturn(queryMock); + when(queryMock.returnBundle(any())).thenReturn(queryMock); + when(queryMock.where(searchParameters)).thenReturn(queryMock); + when(queryMock.withAdditionalHeader(any(), any())).thenReturn(queryMock); + when(queryMock.execute()).thenReturn(resultBundle); + + // when + IBaseBundle result = myGenericClientRepository.search(Bundle.class, Patient.class, searchParameters); + + // then + // Just verify the result, not the exact chain of calls + assertEquals(resultBundle, result); + } + + @Test + void testCapabilities() { + // given + Class conformanceType = IBaseConformance.class; + IBaseConformance mockConformance = mock(IBaseConformance.class); + + when(myGenericClient.capabilities().ofType(conformanceType).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.capabilities().ofType(conformanceType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(mockConformance); + + // when + IBaseConformance result = myGenericClientRepository.capabilities(conformanceType, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.capabilities().ofType(conformanceType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(mockConformance, result); + } + + @Test + void testTransaction() { + // given + IBaseBundle mockInputBundle = mock(IBaseBundle.class); + IBaseBundle mockOutputBundle = mock(IBaseBundle.class); + + when(myGenericClient.transaction().withBundle(mockInputBundle).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.transaction().withBundle(mockInputBundle).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(mockOutputBundle); + + // when + IBaseBundle result = myGenericClientRepository.transaction(mockInputBundle, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.transaction().withBundle(mockInputBundle).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(mockOutputBundle, result); + } + + @Test + void testLink() { + // given + Class bundleType = IBaseBundle.class; + String url = "http://example.com/fhir/Patient?_count=10"; + IBaseBundle mockBundle = mock(IBaseBundle.class); + + when(myGenericClient.loadPage().byUrl(url).andReturnBundle(bundleType).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.loadPage().byUrl(url).andReturnBundle(bundleType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(mockBundle); + + // when + IBaseBundle result = myGenericClientRepository.link(bundleType, url, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.loadPage().byUrl(url).andReturnBundle(bundleType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(mockBundle, result); + } + + @Test + void testInvokeServerLevelReturningResource() { + // given + String operationName = "test-operation"; + IBaseParameters mockParameters = mock(IBaseParameters.class); + Class returnType = IBaseResource.class; + IBaseResource mockResource = mock(IBaseResource.class); + + when(myGenericClient.operation().onServer().named(operationName).withParameters(mockParameters).returnResourceType(returnType).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.operation().onServer().named(operationName).withParameters(mockParameters).returnResourceType(returnType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(mockResource); + + // when + IBaseResource result = myGenericClientRepository.invoke(operationName, mockParameters, returnType, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.operation().onServer().named(operationName).withParameters(mockParameters).returnResourceType(returnType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(mockResource, result); + } + + @Test + void testInvokeServerLevelReturningMethodOutcome() { + // given + String operationName = "test-operation"; + IBaseParameters mockParameters = mock(IBaseParameters.class); + MethodOutcome stubOutcome = new MethodOutcome(); + + when(myGenericClient.operation().onServer().named(operationName).withParameters(mockParameters).returnMethodOutcome().withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.operation().onServer().named(operationName).withParameters(mockParameters).returnMethodOutcome().withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(stubOutcome); + + // when + MethodOutcome result = myGenericClientRepository.invoke(operationName, mockParameters, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.operation().onServer().named(operationName).withParameters(mockParameters).returnMethodOutcome().withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(stubOutcome, result); + } + + @Test + void testInvokeTypeLevelReturningResource() { + // given + Class resourceType = IBaseResource.class; + String operationName = "test-operation"; + IBaseParameters mockParameters = mock(IBaseParameters.class); + Class returnType = IBaseResource.class; + IBaseResource mockResource = mock(IBaseResource.class); + + when(myGenericClient.operation().onType(resourceType).named(operationName).withParameters(mockParameters).returnResourceType(returnType).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.operation().onType(resourceType).named(operationName).withParameters(mockParameters).returnResourceType(returnType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(mockResource); + + // when + IBaseResource result = myGenericClientRepository.invoke(resourceType, operationName, mockParameters, returnType, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.operation().onType(resourceType).named(operationName).withParameters(mockParameters).returnResourceType(returnType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(mockResource, result); + } + + @Test + void testInvokeTypeLevelReturningMethodOutcome() { + // given + Class resourceType = IBaseResource.class; + String operationName = "test-operation"; + IBaseParameters mockParameters = mock(IBaseParameters.class); + MethodOutcome stubOutcome = new MethodOutcome(); + + when(myGenericClient.operation().onType(resourceType).named(operationName).withParameters(mockParameters).returnMethodOutcome().withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.operation().onType(resourceType).named(operationName).withParameters(mockParameters).returnMethodOutcome().withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(stubOutcome); + + // when + MethodOutcome result = myGenericClientRepository.invoke(resourceType, operationName, mockParameters, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.operation().onType(resourceType).named(operationName).withParameters(mockParameters).returnMethodOutcome().withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(stubOutcome, result); + } + + @Test + void testInvokeInstanceLevelReturningResource() { + // given + IIdType mockId = mock(IIdType.class); + String operationName = "test-operation"; + IBaseParameters mockParameters = mock(IBaseParameters.class); + Class returnType = IBaseResource.class; + IBaseResource mockResource = mock(IBaseResource.class); + + when(myGenericClient.operation().onInstance(mockId).named(operationName).withParameters(mockParameters).returnResourceType(returnType).withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.operation().onInstance(mockId).named(operationName).withParameters(mockParameters).returnResourceType(returnType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(mockResource); + + // when + IBaseResource result = myGenericClientRepository.invoke(mockId, operationName, mockParameters, returnType, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.operation().onInstance(mockId).named(operationName).withParameters(mockParameters).returnResourceType(returnType).withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(mockResource, result); + } + + @Test + void testInvokeInstanceLevelReturningMethodOutcome() { + // given + IIdType mockId = mock(IIdType.class); + String operationName = "test-operation"; + IBaseParameters mockParameters = mock(IBaseParameters.class); + MethodOutcome stubOutcome = new MethodOutcome(); + + when(myGenericClient.operation().onInstance(mockId).named(operationName).withParameters(mockParameters).returnMethodOutcome().withAdditionalHeader(any(), any())).thenAnswer(Answers.RETURNS_SELF); + when(myGenericClient.operation().onInstance(mockId).named(operationName).withParameters(mockParameters).returnMethodOutcome().withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123").execute()).thenReturn(stubOutcome); + + // when + MethodOutcome result = myGenericClientRepository.invoke(mockId, operationName, mockParameters, Map.of(HEADER_IF_NONE_MATCH, "abc123")); + + // then + verify(myGenericClient.operation().onInstance(mockId).named(operationName).withParameters(mockParameters).returnMethodOutcome().withAdditionalHeader(HEADER_IF_NONE_MATCH, "abc123")).execute(); + assertEquals(stubOutcome, result); + } + +} From e7609fc14358b12438178640d9eda558494d0a8d Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Mon, 7 Jul 2025 19:44:53 -0400 Subject: [PATCH 12/65] Redundant override --- .../ca/uhn/fhir/repository/IRepository.java | 6 +- .../impl/GenericClientRepository.java | 411 +++++++++--------- 2 files changed, 204 insertions(+), 213 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java index 8c9e49f05dba..805983e1cdd8 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java @@ -28,8 +28,8 @@ import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; import ca.uhn.fhir.rest.server.exceptions.NotImplementedOperationException; import com.google.common.annotations.Beta; -import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; import jakarta.annotation.Nonnull; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseConformance; @@ -305,9 +305,7 @@ default B search( Class resourceType, Map> searchParameters, Map headers) { - ArrayListMultimap> multimap = ArrayListMultimap.create(); - searchParameters.forEach(multimap::put); - return this.search(bundleType, resourceType, multimap, headers); + return this.search(bundleType, resourceType, Multimaps.forMap(searchParameters), headers); } // Paging starts here diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java index b6887cc24754..889a85f4495d 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java @@ -11,7 +11,6 @@ import ca.uhn.fhir.rest.gclient.IUntypedQuery; import ca.uhn.fhir.util.ParametersUtil; import com.google.common.collect.Multimap; -import com.google.common.collect.Multimaps; import jakarta.annotation.Nonnull; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseConformance; @@ -30,220 +29,214 @@ */ public class GenericClientRepository implements IRepository { - public GenericClientRepository(IGenericClient theGenericClient) { - this.myGenericClient = theGenericClient; - } - - private final IGenericClient myGenericClient; - - protected IGenericClient getClient() { - return this.myGenericClient; - } - - @Override - public T read( - Class theResourceType, I theId, Map theHeaders) { - var op = this.myGenericClient.read().resource(theResourceType).withId(theId); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public MethodOutcome create(T theResource, Map theHeaders) { - var op = this.myGenericClient.create().resource(theResource); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public MethodOutcome patch( - I theId, P thePatchparameters, Map theHeaders) { - var op = this.myGenericClient.patch().withFhirPatch(thePatchparameters).withId(theId); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public MethodOutcome update(T theResource, Map theHeaders) { - var op = this.myGenericClient.update().resource(theResource).withId(theResource.getIdElement()); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public MethodOutcome delete( - Class theResourcetype, I theId, Map theHeaders) { - var op = this.myGenericClient.delete().resourceById(theId); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public B search( - Class theBundleType, - Class theSearchResourceType, - Multimap> theSearchParameters, - Map theHeaders) { + public GenericClientRepository(IGenericClient theGenericClient) { + this.myGenericClient = theGenericClient; + } + + private final IGenericClient myGenericClient; + + protected IGenericClient getClient() { + return this.myGenericClient; + } + + @Override + public T read( + Class theResourceType, I theId, Map theHeaders) { + var op = this.myGenericClient.read().resource(theResourceType).withId(theId); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public MethodOutcome create(T theResource, Map theHeaders) { + var op = this.myGenericClient.create().resource(theResource); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public MethodOutcome patch( + I theId, P thePatchparameters, Map theHeaders) { + var op = this.myGenericClient.patch().withFhirPatch(thePatchparameters).withId(theId); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public MethodOutcome update(T theResource, Map theHeaders) { + var op = this.myGenericClient.update().resource(theResource).withId(theResource.getIdElement()); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public MethodOutcome delete( + Class theResourcetype, I theId, Map theHeaders) { + var op = this.myGenericClient.delete().resourceById(theId); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public B search( + Class theBundleType, + Class theSearchResourceType, + Multimap> theSearchParameters, + Map theHeaders) { IUntypedQuery search = this.myGenericClient.search(); IQuery iBaseBundleIQuery = search.forResource(theSearchResourceType); var op = iBaseBundleIQuery.returnBundle(theBundleType); if (theSearchParameters != null) { - theSearchParameters.entries().forEach(e-> - op.where(Map.of(e.getKey(), e.getValue()))); + theSearchParameters.entries().forEach(e -> op.where(Map.of(e.getKey(), e.getValue()))); } return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public C capabilities(Class theResourceType, Map theHeaders) { + var op = this.myGenericClient.capabilities().ofType(theResourceType); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public B transaction(B theBundle, Map theHeaders) { + var op = this.myGenericClient.transaction().withBundle(theBundle); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public B link(Class theBundleType, String url, Map theHeaders) { + var op = this.myGenericClient.loadPage().byUrl(url).andReturnBundle(theBundleType); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public R invoke( + String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient + .operation() + .onServer() + .named(theOperationName) + .withParameters(theParameters) + .returnResourceType(theReturnType); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public

MethodOutcome invoke( + String theOperationName, P theParameters, Map theHeaders) { + var op = this.myGenericClient + .operation() + .onServer() + .named(theOperationName) + .withParameters(theParameters) + .returnMethodOutcome(); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public R invoke( + Class theResourceType, + String theOperationName, + P theParameters, + Class theReturnType, + Map theHeaders) { + var op = this.myGenericClient + .operation() + .onType(theResourceType) + .named(theOperationName) + .withParameters(theParameters) + .returnResourceType(theReturnType); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public

MethodOutcome invoke( + Class theResourceType, String theOperationName, P parameters, Map theHeaders) { + var op = this.myGenericClient + .operation() + .onType(theResourceType) + .named(theOperationName) + .withParameters(parameters) + .returnMethodOutcome(); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public R invoke( + I theId, String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient + .operation() + .onInstance(theId) + .named(theOperationName) + .withParameters(theParameters) + .returnResourceType(theReturnType); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public

MethodOutcome invoke( + I theResourceId, String theOperationName, P theParameters, Map theHeaders) { + var op = this.myGenericClient + .operation() + .onInstance(theResourceId) + .named(theOperationName) + .withParameters(theParameters) + .returnMethodOutcome(); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public B history( + P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient.history().onServer().returnBundle(theReturnType); + this.addHistoryParams(null, theParameters); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public B history( + Class theResourceType, P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient.history().onType(theResourceType).returnBundle(theReturnType); + this.addHistoryParams(op, theParameters); + return this.addHeaders(op, theHeaders).execute(); + } + + @Override + public B history( + I theResourceId, P theParameters, Class theReturnType, Map theHeaders) { + var op = this.myGenericClient.history().onInstance(theResourceId).returnBundle(theReturnType); + this.addHistoryParams(null, theParameters); + return this.addHeaders(op, theHeaders).execute(); + } + + @Nonnull + @Override + public FhirContext fhirContext() { + return this.getClient().getFhirContext(); + } + + @SuppressWarnings("unchecked") + protected void addHistoryParams( + IHistoryTyped operation, P parameters) { + + var ctx = this.myGenericClient.getFhirContext(); + var count = ParametersUtil.getNamedParameterValuesAsInteger(ctx, parameters, "_count"); + if (count != null && !count.isEmpty()) { + operation.count(count.get(0)); + } + + // TODO: Figure out how to handle date ranges for the _at parameter + + var since = ParametersUtil.getNamedParameter(ctx, parameters, "_since"); + if (since.isPresent()) { + operation.since((IPrimitiveType) since.get()); + } + } + + protected > T addHeaders(T op, Map headers) { + if (headers != null) { + for (var entry : headers.entrySet()) { + op = op.withAdditionalHeader(entry.getKey(), entry.getValue()); + } + } - } - - @Override - public B search( - Class theBundleType, - Class theResourceType, - Map> theSearchParameters, - Map theHeaders) { - return search(theBundleType, theResourceType, Multimaps.forMap(theSearchParameters), theHeaders); - } - - @Override - public C capabilities(Class theResourceType, Map theHeaders) { - var op = this.myGenericClient.capabilities().ofType(theResourceType); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public B transaction(B theBundle, Map theHeaders) { - var op = this.myGenericClient.transaction().withBundle(theBundle); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public B link(Class theBundleType, String url, Map theHeaders) { - var op = this.myGenericClient.loadPage().byUrl(url).andReturnBundle(theBundleType); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public R invoke( - String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient - .operation() - .onServer() - .named(theOperationName) - .withParameters(theParameters) - .returnResourceType(theReturnType); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public

MethodOutcome invoke(String theOperationName, P theParameters, Map theHeaders) { - var op = this.myGenericClient - .operation() - .onServer() - .named(theOperationName) - .withParameters(theParameters) - .returnMethodOutcome(); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public R invoke( - Class theResourceType, String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient - .operation() - .onType(theResourceType) - .named(theOperationName) - .withParameters(theParameters) - .returnResourceType(theReturnType); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public

MethodOutcome invoke( - Class theResourceType, String theOperationName, P parameters, Map theHeaders) { - var op = this.myGenericClient - .operation() - .onType(theResourceType) - .named(theOperationName) - .withParameters(parameters) - .returnMethodOutcome(); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public R invoke( - I theId, String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient - .operation() - .onInstance(theId) - .named(theOperationName) - .withParameters(theParameters) - .returnResourceType(theReturnType); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public

MethodOutcome invoke( - I theResourceId, String theOperationName, P theParameters, Map theHeaders) { - var op = this.myGenericClient - .operation() - .onInstance(theResourceId) - .named(theOperationName) - .withParameters(theParameters) - .returnMethodOutcome(); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public B history( - P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient.history().onServer().returnBundle(theReturnType); - this.addHistoryParams(null, theParameters); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public B history( - Class theResourceType, P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient.history().onType(theResourceType).returnBundle(theReturnType); - this.addHistoryParams(op, theParameters); - return this.addHeaders(op, theHeaders).execute(); - } - - @Override - public B history( - I theResourceId, P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient.history().onInstance(theResourceId).returnBundle(theReturnType); - this.addHistoryParams(null, theParameters); - return this.addHeaders(op, theHeaders).execute(); - } - - @Nonnull - @Override - public FhirContext fhirContext() { - return this.getClient().getFhirContext(); - } - - @SuppressWarnings("unchecked") - protected void addHistoryParams( - IHistoryTyped operation, P parameters) { - - var ctx = this.myGenericClient.getFhirContext(); - var count = ParametersUtil.getNamedParameterValuesAsInteger(ctx, parameters, "_count"); - if (count != null && !count.isEmpty()) { - operation.count(count.get(0)); - } - - // TODO: Figure out how to handle date ranges for the _at parameter - - var since = ParametersUtil.getNamedParameter(ctx, parameters, "_since"); - if (since.isPresent()) { - operation.since((IPrimitiveType) since.get()); - } - } - - protected > T addHeaders(T op, Map headers) { - if (headers != null) { - for (var entry : headers.entrySet()) { - op = op.withAdditionalHeader(entry.getKey(), entry.getValue()); - } - } - - return op; - } + return op; + } } From e37c382fa2026f9381132ceeee835cc3639c8220 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Mon, 7 Jul 2025 19:51:59 -0400 Subject: [PATCH 13/65] Javadoc --- .../java/ca/uhn/fhir/repository/Repositories.java | 12 ++++++++++-- .../java/ca/uhn/fhir/repository/package-info.java | 12 +++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java index fabaf0129a18..52fd2ca35215 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java @@ -5,12 +5,20 @@ import ca.uhn.fhir.repository.impl.InMemoryFhirRepository; import ca.uhn.fhir.rest.client.api.IGenericClient; +/** + * Static factory methods for creating instances of {@link IRepository}. + */ public class Repositories { - public IRepository emptyInMemoryRepository(FhirContext theFhirContext) { + public static IRepository emptyInMemoryRepository(FhirContext theFhirContext) { return InMemoryFhirRepository.emptyRepository(theFhirContext); } - public IRepository restClientRepository(IGenericClient theGenericClient) { + public static IRepository restClientRepository(IGenericClient theGenericClient) { return new GenericClientRepository(theGenericClient); } + + /** + * Private constructor to prevent instantiation. + */ + Repositories() {} } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/package-info.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/package-info.java index 035b6bf56264..adcc1164750f 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/package-info.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/package-info.java @@ -1,6 +1,12 @@ /** - * This package contains the abstract repository interface and a simple implementation. - * The InMemoryRepository is a simple in-memory implementation suitable for testing. - * Use the Repositoiries class to create an empty in-memory repository. + * This package provides an interface and implementations abstracting + * access to a FHIR repository. + *

+ * + * + * Use the {@link ca.uhn.fhir.repository.Repositories} class to create instances. */ package ca.uhn.fhir.repository; From d1a80adc2a1ce84e8ead570fde3d9779fd8cc297 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Tue, 8 Jul 2025 12:40:06 -0400 Subject: [PATCH 14/65] House style --- .../impl/GenericClientRepository.java | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java index 889a85f4495d..e737869bb090 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java @@ -29,47 +29,47 @@ */ public class GenericClientRepository implements IRepository { + private final IGenericClient myGenericClient; + public GenericClientRepository(IGenericClient theGenericClient) { - this.myGenericClient = theGenericClient; + myGenericClient = theGenericClient; } - private final IGenericClient myGenericClient; - protected IGenericClient getClient() { - return this.myGenericClient; + return myGenericClient; } @Override public T read( Class theResourceType, I theId, Map theHeaders) { - var op = this.myGenericClient.read().resource(theResourceType).withId(theId); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.read().resource(theResourceType).withId(theId); + return addHeaders(op, theHeaders).execute(); } @Override public MethodOutcome create(T theResource, Map theHeaders) { - var op = this.myGenericClient.create().resource(theResource); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.create().resource(theResource); + return addHeaders(op, theHeaders).execute(); } @Override public MethodOutcome patch( I theId, P thePatchparameters, Map theHeaders) { - var op = this.myGenericClient.patch().withFhirPatch(thePatchparameters).withId(theId); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.patch().withFhirPatch(thePatchparameters).withId(theId); + return addHeaders(op, theHeaders).execute(); } @Override public MethodOutcome update(T theResource, Map theHeaders) { - var op = this.myGenericClient.update().resource(theResource).withId(theResource.getIdElement()); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.update().resource(theResource).withId(theResource.getIdElement()); + return addHeaders(op, theHeaders).execute(); } @Override public MethodOutcome delete( Class theResourcetype, I theId, Map theHeaders) { - var op = this.myGenericClient.delete().resourceById(theId); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.delete().resourceById(theId); + return addHeaders(op, theHeaders).execute(); } @Override @@ -78,56 +78,56 @@ public B search( Class theSearchResourceType, Multimap> theSearchParameters, Map theHeaders) { - IUntypedQuery search = this.myGenericClient.search(); + IUntypedQuery search = myGenericClient.search(); IQuery iBaseBundleIQuery = search.forResource(theSearchResourceType); var op = iBaseBundleIQuery.returnBundle(theBundleType); if (theSearchParameters != null) { theSearchParameters.entries().forEach(e -> op.where(Map.of(e.getKey(), e.getValue()))); } - return this.addHeaders(op, theHeaders).execute(); + return addHeaders(op, theHeaders).execute(); } @Override public C capabilities(Class theResourceType, Map theHeaders) { - var op = this.myGenericClient.capabilities().ofType(theResourceType); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.capabilities().ofType(theResourceType); + return addHeaders(op, theHeaders).execute(); } @Override public B transaction(B theBundle, Map theHeaders) { - var op = this.myGenericClient.transaction().withBundle(theBundle); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.transaction().withBundle(theBundle); + return addHeaders(op, theHeaders).execute(); } @Override public B link(Class theBundleType, String url, Map theHeaders) { - var op = this.myGenericClient.loadPage().byUrl(url).andReturnBundle(theBundleType); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.loadPage().byUrl(url).andReturnBundle(theBundleType); + return addHeaders(op, theHeaders).execute(); } @Override public R invoke( String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient + var op = myGenericClient .operation() .onServer() .named(theOperationName) .withParameters(theParameters) .returnResourceType(theReturnType); - return this.addHeaders(op, theHeaders).execute(); + return addHeaders(op, theHeaders).execute(); } @Override public

MethodOutcome invoke( String theOperationName, P theParameters, Map theHeaders) { - var op = this.myGenericClient + var op = myGenericClient .operation() .onServer() .named(theOperationName) .withParameters(theParameters) .returnMethodOutcome(); - return this.addHeaders(op, theHeaders).execute(); + return addHeaders(op, theHeaders).execute(); } @Override @@ -137,86 +137,86 @@ public theReturnType, Map theHeaders) { - var op = this.myGenericClient + var op = myGenericClient .operation() .onType(theResourceType) .named(theOperationName) .withParameters(theParameters) .returnResourceType(theReturnType); - return this.addHeaders(op, theHeaders).execute(); + return addHeaders(op, theHeaders).execute(); } @Override public

MethodOutcome invoke( Class theResourceType, String theOperationName, P parameters, Map theHeaders) { - var op = this.myGenericClient + var op = myGenericClient .operation() .onType(theResourceType) .named(theOperationName) .withParameters(parameters) .returnMethodOutcome(); - return this.addHeaders(op, theHeaders).execute(); + return addHeaders(op, theHeaders).execute(); } @Override public R invoke( I theId, String theOperationName, P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient + var op = myGenericClient .operation() .onInstance(theId) .named(theOperationName) .withParameters(theParameters) .returnResourceType(theReturnType); - return this.addHeaders(op, theHeaders).execute(); + return addHeaders(op, theHeaders).execute(); } @Override public

MethodOutcome invoke( I theResourceId, String theOperationName, P theParameters, Map theHeaders) { - var op = this.myGenericClient + var op = myGenericClient .operation() .onInstance(theResourceId) .named(theOperationName) .withParameters(theParameters) .returnMethodOutcome(); - return this.addHeaders(op, theHeaders).execute(); + return addHeaders(op, theHeaders).execute(); } @Override public B history( P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient.history().onServer().returnBundle(theReturnType); - this.addHistoryParams(null, theParameters); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.history().onServer().returnBundle(theReturnType); + addHistoryParams(null, theParameters); + return addHeaders(op, theHeaders).execute(); } @Override public B history( Class theResourceType, P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient.history().onType(theResourceType).returnBundle(theReturnType); - this.addHistoryParams(op, theParameters); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.history().onType(theResourceType).returnBundle(theReturnType); + addHistoryParams(op, theParameters); + return addHeaders(op, theHeaders).execute(); } @Override public B history( I theResourceId, P theParameters, Class theReturnType, Map theHeaders) { - var op = this.myGenericClient.history().onInstance(theResourceId).returnBundle(theReturnType); - this.addHistoryParams(null, theParameters); - return this.addHeaders(op, theHeaders).execute(); + var op = myGenericClient.history().onInstance(theResourceId).returnBundle(theReturnType); + addHistoryParams(null, theParameters); + return addHeaders(op, theHeaders).execute(); } @Nonnull @Override public FhirContext fhirContext() { - return this.getClient().getFhirContext(); + return getClient().getFhirContext(); } @SuppressWarnings("unchecked") protected void addHistoryParams( IHistoryTyped operation, P parameters) { - var ctx = this.myGenericClient.getFhirContext(); + var ctx = myGenericClient.getFhirContext(); var count = ParametersUtil.getNamedParameterValuesAsInteger(ctx, parameters, "_count"); if (count != null && !count.isEmpty()) { operation.count(count.get(0)); From 182966d08a1e6110a556ef4b1a30d0f9c87a4a14 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Tue, 8 Jul 2025 17:28:01 -0400 Subject: [PATCH 15/65] Add url-based service loader. --- .../ca/uhn/fhir/repository/IRepository.java | 3 + .../ca/uhn/fhir/repository/Repositories.java | 23 +++++ .../repository/impl/IRepositoryLoader.java | 41 +++++++++ .../impl/InMemoryFhirRepositoryLoader.java | 27 ++++++ .../impl/SchemeBasedFhirRepositoryLoader.java | 17 ++++ .../repository/impl/UrlRepositoryFactory.java | 85 +++++++++++++++++++ ...uhn.fhir.repository.impl.IRepositoryLoader | 1 + .../InMemoryFhirRepositoryLoaderTest.java | 57 +++++++++++++ .../impl/UrlRepositoryFactoryTest.java | 43 ++++++++++ 9 files changed, 297 insertions(+) create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/IRepositoryLoader.java create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/SchemeBasedFhirRepositoryLoader.java create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java create mode 100644 hapi-fhir-base/src/main/resources/META-INF/services/ca.uhn.fhir.repository.impl.IRepositoryLoader create mode 100644 hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoaderTest.java create mode 100644 hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactoryTest.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java index 805983e1cdd8..60adaa42562a 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/IRepository.java @@ -36,6 +36,8 @@ import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.Collections; import java.util.List; @@ -86,6 +88,7 @@ */ @Beta public interface IRepository { + Logger ourLog = LoggerFactory.getLogger(IRepository.class.getPackageName()); // CRUD starts here diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java index 52fd2ca35215..4057e63c5941 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java @@ -3,7 +3,10 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.repository.impl.GenericClientRepository; import ca.uhn.fhir.repository.impl.InMemoryFhirRepository; +import ca.uhn.fhir.repository.impl.UrlRepositoryFactory; import ca.uhn.fhir.rest.client.api.IGenericClient; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; /** * Static factory methods for creating instances of {@link IRepository}. @@ -21,4 +24,24 @@ public static IRepository restClientRepository(IGenericClient theGenericClient) * Private constructor to prevent instantiation. */ Repositories() {} + + public static boolean isRepositoryUrl(String theBaseUrl) { + return UrlRepositoryFactory.isRepositoryUrl(theBaseUrl); + } + + /** + * Constructs a version of {@link IRepository} based on the given URL. + * These URLs are expected to be in the form of fhir-repository:subscheme:details. + * Currently supported subschemes include: + *

    + *
  • memory - e.g. fhir-repository:memory:my-repo - the last piece (my-repo) identifies the repository
  • + *
+ * @param theBaseUrl a url of the form fhir-repository:subscheme:details + * @param theFhirContext the FHIR context to use for the repository, if required. + * @return + */ + @Nonnull + public static IRepository repositoryForUrl(@Nonnull String theBaseUrl, @Nullable FhirContext theFhirContext) { + return UrlRepositoryFactory.buildRepository(theBaseUrl, theFhirContext); + } } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/IRepositoryLoader.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/IRepositoryLoader.java new file mode 100644 index 000000000000..6171c8555548 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/IRepositoryLoader.java @@ -0,0 +1,41 @@ +package ca.uhn.fhir.repository.impl; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.repository.IRepository; +import com.google.common.annotations.Beta; +import jakarta.annotation.Nonnull; + +import java.util.Optional; + +/** + * Service provider interface for loading repositories based on a URL. + * Unstable API. Subject to change in future releases. + */ +@Beta() +public interface IRepositoryLoader { + /** + * Impelmentors should return true if they can handle the given URL. + * @param theRepositoryRequest containing the URL to check + * @return true if supported + */ + boolean canLoad(IRepositoryRequest theRepositoryRequest); + + /** + * Construct a version of {@link IRepository} based on the given URL. + * Implementors can assume that the request passed the canLoad() check. + * + * @param theRepositoryRequest the details of the repository to load. + * @return a repository instance + */ + @Nonnull + IRepository loadRepository(IRepositoryRequest theRepositoryRequest); + + interface IRepositoryRequest { + String getUrl(); + String getSubScheme(); + String getDetails(); + Optional getFhirContext(); + } + + +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java new file mode 100644 index 000000000000..972eebf8b908 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java @@ -0,0 +1,27 @@ +package ca.uhn.fhir.repository.impl; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.repository.IRepository; +import jakarta.annotation.Nonnull; +import org.apache.commons.collections4.map.ReferenceMap; + +public class InMemoryFhirRepositoryLoader extends SchemeBasedFhirRepositoryLoader implements IRepositoryLoader { + + public static final String URL_SUB_SCHEME = "memory"; + static final ReferenceMap ourRepositories = new ReferenceMap<>(); + + public InMemoryFhirRepositoryLoader() { + super(URL_SUB_SCHEME); + } + + @Nonnull + @Override + public IRepository loadRepository(IRepositoryRequest theRepositoryRequest) { + FhirContext context = theRepositoryRequest.getFhirContext() + // fixme hapi-code + .orElseThrow(()-> new IllegalArgumentException("The :memory: FHIR repository requires a FhirContext.")); + + String memoryKey = theRepositoryRequest.getDetails(); + return ourRepositories.computeIfAbsent(memoryKey, k->InMemoryFhirRepository.emptyRepository(context)); + } +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/SchemeBasedFhirRepositoryLoader.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/SchemeBasedFhirRepositoryLoader.java new file mode 100644 index 000000000000..c46dc4398335 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/SchemeBasedFhirRepositoryLoader.java @@ -0,0 +1,17 @@ +package ca.uhn.fhir.repository.impl; + +public abstract class SchemeBasedFhirRepositoryLoader implements IRepositoryLoader { + final String myScheme; + + protected SchemeBasedFhirRepositoryLoader(String theScheme) { + myScheme = theScheme; + } + + public boolean canLoad(IRepositoryLoader.IRepositoryRequest theRepositoryRequest) { + if (theRepositoryRequest == null) { + return false; + } + + return myScheme.equals(theRepositoryRequest.getSubScheme()); + } +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java new file mode 100644 index 000000000000..2057c76835c2 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java @@ -0,0 +1,85 @@ +package ca.uhn.fhir.repository.impl; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.repository.IRepository; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import org.slf4j.Logger; + +import java.util.Objects; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class UrlRepositoryFactory { + private static final Logger ourLog = IRepository.ourLog; + + public static final String FHIR_REPOSITORY_URL_SCHEME = "fhir-repository:"; + static final Pattern ourUrlPattern = Pattern.compile("^fhir-repository:([A-Za-z]+):(.*)"); + + public static boolean isRepositoryUrl(String theBaseUrl) { + return theBaseUrl != null && + theBaseUrl.startsWith(FHIR_REPOSITORY_URL_SCHEME) && + ourUrlPattern.matcher(theBaseUrl).matches(); + } + + protected record RepositoryRequest(String url, String subScheme, String details, FhirContext fhirContext) implements IRepositoryLoader.IRepositoryRequest { + @Override + public String getUrl() { + return url; + } + + @Override + public String getSubScheme() { + return subScheme; + } + + @Override + public String getDetails() { + return details; + } + + @Override + public Optional getFhirContext() { + return Optional.ofNullable(fhirContext); + } + } + + @Nonnull + public static IRepository buildRepository(@Nonnull String theBaseUrl, @Nullable FhirContext theFhirContext) { + ourLog.debug("Loading repository for url: {}", theBaseUrl); + Objects.requireNonNull(theBaseUrl); + + if (!isRepositoryUrl(theBaseUrl)) { + // fixme hapi-code + throw new IllegalArgumentException(Msg.code(99997) + "Base URL is not a valid repository URL: " + theBaseUrl); + } + + ServiceLoader load = ServiceLoader.load(IRepositoryLoader.class); + IRepositoryLoader.IRepositoryRequest request = buildRequest(theBaseUrl, theFhirContext); + for (IRepositoryLoader nextLoader : load) { + ourLog.debug("Checking repository loader {}", nextLoader.getClass().getName()); + if (nextLoader.canLoad(request)) { + return nextLoader.loadRepository(request); + } + } + // fixme hapi-code + throw new IllegalArgumentException(Msg.code(99999) + "Unable to find a repository loader for URL: " + theBaseUrl); + } + + @Nonnull + protected static RepositoryRequest buildRequest(@Nonnull String theBaseUrl, @Nullable FhirContext theFhirContext) { + Matcher matcher = ourUrlPattern.matcher(theBaseUrl); + String subScheme = null; + String details = null; + boolean found = matcher.matches(); + if (found) { + subScheme = matcher.group(1); + details = matcher.group(2); + } + + return new RepositoryRequest(theBaseUrl, subScheme, details, theFhirContext); + } +} diff --git a/hapi-fhir-base/src/main/resources/META-INF/services/ca.uhn.fhir.repository.impl.IRepositoryLoader b/hapi-fhir-base/src/main/resources/META-INF/services/ca.uhn.fhir.repository.impl.IRepositoryLoader new file mode 100644 index 000000000000..144d0e55f2d9 --- /dev/null +++ b/hapi-fhir-base/src/main/resources/META-INF/services/ca.uhn.fhir.repository.impl.IRepositoryLoader @@ -0,0 +1 @@ +ca.uhn.fhir.repository.impl.InMemoryFhirRepositoryLoader diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoaderTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoaderTest.java new file mode 100644 index 000000000000..939cd6aa1be0 --- /dev/null +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoaderTest.java @@ -0,0 +1,57 @@ +package ca.uhn.fhir.repository.impl; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.repository.IRepository; +import ca.uhn.fhir.repository.Repositories; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; + +import static ca.uhn.fhir.repository.impl.UrlRepositoryFactory.FHIR_REPOSITORY_URL_SCHEME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@MockitoSettings +class InMemoryFhirRepositoryLoaderTest { + + @Mock + FhirContext myFhirContext; + InMemoryFhirRepositoryLoader myMemoryFhirRepositoryLoader = new InMemoryFhirRepositoryLoader(); + + @ParameterizedTest + @CsvSource(textBlock = """ + fhir-repository:memory:my-repo, true + fhir-repository:baz:my-repo, false + memory:my-repo, false + """) + void testUrlCheck(String theUrl, boolean theExpectedResult) { + boolean result = myMemoryFhirRepositoryLoader.canLoad(UrlRepositoryFactory.buildRequest(theUrl, myFhirContext)); + assertEquals(theExpectedResult, result); + } + + @Test + void testSameSlugGivesSameRepository() { + // given + String url = FHIR_REPOSITORY_URL_SCHEME + "memory:my-repo"; + // when + IRepository repository1 = Repositories.repositoryForUrl(url, myFhirContext); + IRepository repository2 = Repositories.repositoryForUrl(url, myFhirContext); + + // then + assertSame(repository1, repository2); + } + + @Test + void testDiscoveryThroughServiceLoaderFacade() { + // when + IRepository repository = Repositories.repositoryForUrl(FHIR_REPOSITORY_URL_SCHEME + "memory:my-repo", myFhirContext); + + // then + assertThat(repository).isNotNull() + .isInstanceOf(InMemoryFhirRepository.class); + } + + +} diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactoryTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactoryTest.java new file mode 100644 index 000000000000..b2bd53692b99 --- /dev/null +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactoryTest.java @@ -0,0 +1,43 @@ +package ca.uhn.fhir.repository.impl; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class UrlRepositoryFactoryTest { + /** + * Check if we have a well-formatted fhir-repository: url. + * Note: We may decide to support http: urls via {@link GenericClientRepository} later. + */ + @ParameterizedTest + @CsvSource(textBlock = """ + false, + false, "" + false, http://localhost/ + false, https://localhost/ + true, fhir-repository:provider:config + false, fhir-repository/provider:config + false, fhir-repository:provider/config + false, fhir-repository:provider + true, fhir-repository:provider: + """) + void testIsUrl(boolean theExpectedResult, String theUrl) { + assertEquals(theExpectedResult, UrlRepositoryFactory.isRepositoryUrl(theUrl)); + } + + @Test + void testUrlParse() { + // given + String url = "fhir-repository:provider:config"; + + // when + var request = UrlRepositoryFactory.buildRequest(url, null); + + // then + assertEquals("provider", request.getSubScheme()); + assertEquals("config", request.getDetails()); + } + +} From cc48e42ea31f45c2c73d4f2a810ebdde8bdc506e Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 11 Jul 2025 16:41:01 -0400 Subject: [PATCH 16/65] New parts record for bundle processing --- .../java/ca/uhn/fhir/util/BundleUtil.java | 3 +- .../java/ca/uhn/fhir/util/ElementUtil.java | 41 +++++- .../fhir/util/bundle/BundleEntryParts.java | 9 +- .../util/bundle/BundleResponseEntryParts.java | 122 ++++++++++++++++++ .../uhn/fhir/util/bundle/PartsConverter.java | 9 ++ 5 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleResponseEntryParts.java create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/PartsConverter.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java index f3356b322385..5bdc3b991ab3 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java @@ -73,6 +73,7 @@ public class BundleUtil { public static final String DIFFERENT_LINK_ERROR_MSG = "Mismatching 'previous' and 'prev' links exist. 'previous' " + "is: '$PREVIOUS' and 'prev' is: '$PREV'."; + public static final String BUNDLE_TYPE_TRANSACTION_RESPONSE = "transaction-response"; private static final Logger ourLog = LoggerFactory.getLogger(BundleUtil.class); private static final String PREVIOUS = LINK_PREV; @@ -723,7 +724,7 @@ private static BundleEntryParts getBundleEntryParts( } } } - return new BundleEntryParts(fullUrl, requestType, url, resource, conditionalUrl); + return new BundleEntryParts(fullUrl, requestType, url, resource, conditionalUrl, requestType); } /** diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ElementUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ElementUtil.java index 121adb41fe8a..b62838e55b52 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ElementUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/ElementUtil.java @@ -19,16 +19,30 @@ */ package ca.uhn.fhir.util; +import ca.uhn.fhir.context.BaseRuntimeChildDefinition; +import ca.uhn.fhir.context.BaseRuntimeElementDefinition; import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.model.api.ICompositeElement; import ca.uhn.fhir.model.api.IElement; +import jakarta.annotation.Nullable; 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 java.util.ArrayList; +import java.util.Date; import java.util.List; +import java.util.function.Function; public class ElementUtil { + public static final Function CONVERT_PRIMITIVE_TO_STRING = + e -> ((IPrimitiveType) e).getValueAsString(); + public static final Function CAST_BASE_TO_RESOURCE = e -> (IBaseResource) e; + + @SuppressWarnings("unchecked") + public static final Function> CAST_TO_PRIMITIVE_DATE = e -> ((IPrimitiveType) e); + @SuppressWarnings("unchecked") public static boolean isEmpty(Object... theElements) { if (theElements == null) { @@ -100,7 +114,7 @@ public static boolean isEmpty(List theElements) { * Note that this method does not work on HL7.org structures */ public static List allPopulatedChildElements(Class theType, Object... theElements) { - ArrayList retVal = new ArrayList(); + ArrayList retVal = new ArrayList<>(); for (Object next : theElements) { if (next == null) { continue; @@ -132,4 +146,29 @@ private static void addElement(ArrayList retVal, IElemen retVal.addAll(iCompositeElement.getAllPopulatedChildElementsOfType(theType)); } } + + public static IBase setValue(IBase theTarget, BaseRuntimeChildDefinition theChildDef, Object theValue) { + BaseRuntimeElementDefinition elementDefinition = theChildDef.getChildByName(theChildDef.getElementName()); + IBase value; + if (theValue == null || elementDefinition.getImplementingClass().isInstance(theValue)) { + value = (IBase) theValue; + } else { + value = elementDefinition.newInstance(theValue); + } + theChildDef.getMutator().setValue(theTarget, value); + return value; + } + + @Nullable + public static T getSingleValueOrNull( + IBase theParentElement, BaseRuntimeChildDefinition theChildDefinition, Function theConverter) { + if (theParentElement == null) { + return null; + } + return theChildDefinition + .getAccessor() + .getFirstValueOrNull(theParentElement) + .map(theConverter) + .orElse(null); + } } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleEntryParts.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleEntryParts.java index f7b43ab766e7..50082541bcde 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleEntryParts.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleEntryParts.java @@ -28,19 +28,22 @@ public class BundleEntryParts { private final String myUrl; private final String myConditionalUrl; private final String myFullUrl; + private final RequestTypeEnum myMethod; public BundleEntryParts( String theFullUrl, RequestTypeEnum theRequestType, String theUrl, IBaseResource theResource, - String theConditionalUrl) { + String theConditionalUrl, + RequestTypeEnum theMethod) { super(); myFullUrl = theFullUrl; myRequestType = theRequestType; myUrl = theUrl; myResource = theResource; myConditionalUrl = theConditionalUrl; + myMethod = theMethod; } public String getFullUrl() { @@ -62,4 +65,8 @@ public String getConditionalUrl() { public String getUrl() { return myUrl; } + + public RequestTypeEnum getMethod() { + return myMethod; + } } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleResponseEntryParts.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleResponseEntryParts.java new file mode 100644 index 000000000000..2be239f478ba --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleResponseEntryParts.java @@ -0,0 +1,122 @@ +package ca.uhn.fhir.util.bundle; + +import ca.uhn.fhir.context.BaseRuntimeChildDefinition; +import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.util.ElementUtil; +import jakarta.annotation.Nullable; +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 java.util.Date; +import java.util.Objects; +import java.util.function.Function; + +import static ca.uhn.fhir.util.ElementUtil.getSingleValueOrNull; +import static ca.uhn.fhir.util.ElementUtil.setValue; + +/** + * Components of a transaction-respose bundle entry. + */ +public record BundleResponseEntryParts( + String fullUrl, + IBaseResource resource, + String responseStatus, + String responseLocation, + String responseEtag, + IPrimitiveType responseLastModified, + IBaseResource responseOutcome) { + + static class Metadata implements PartsConverter { + + private final BaseRuntimeChildDefinition myFullUrlChildDef; + private final BaseRuntimeChildDefinition myResourceChildDef; + private final BaseRuntimeChildDefinition myResponseChildDef; + private final BaseRuntimeChildDefinition myResponseOutcomeChildDef; + private final BaseRuntimeChildDefinition myResponseStatusChildDef; + private final BaseRuntimeChildDefinition myResponseLocation; + private final BaseRuntimeChildDefinition myResponseEtag; + private final BaseRuntimeChildDefinition myResponseLastModified; + private final BaseRuntimeElementCompositeDefinition myEntryElementDef; + private final BaseRuntimeElementCompositeDefinition myResponseChildContentsDef; + + public Metadata(FhirContext theFhirContext) { + + BaseRuntimeChildDefinition entryChildDef = + theFhirContext.getResourceDefinition("Bundle").getChildByName("entry"); + + myEntryElementDef = (BaseRuntimeElementCompositeDefinition) entryChildDef.getChildByName("entry"); + + myFullUrlChildDef = myEntryElementDef.getChildByName("fullUrl"); + myResourceChildDef = myEntryElementDef.getChildByName("resource"); + + myResponseChildDef = myEntryElementDef.getChildByName("response"); + + myResponseChildContentsDef = + (BaseRuntimeElementCompositeDefinition) myResponseChildDef.getChildByName("response"); + + myResponseOutcomeChildDef = myResponseChildContentsDef.getChildByName("outcome"); + myResponseStatusChildDef = myResponseChildContentsDef.getChildByName("status"); + myResponseLocation = myResponseChildContentsDef.getChildByName("location"); + myResponseEtag = myResponseChildContentsDef.getChildByName("etag"); + myResponseLastModified = myResponseChildContentsDef.getChildByName("lastModified"); + } + + @Override + @Nullable + public BundleResponseEntryParts fromElement(IBase base) { + if (base == null) { + return null; + } + + IBase response = getSingleValueOrNull(base, myResponseChildDef, Function.identity()); + + return new BundleResponseEntryParts( + getSingleValueOrNull(base, myFullUrlChildDef, ElementUtil.CONVERT_PRIMITIVE_TO_STRING), + getSingleValueOrNull(base, myResourceChildDef, ElementUtil.CAST_BASE_TO_RESOURCE), + getSingleValueOrNull(response, myResponseStatusChildDef, ElementUtil.CONVERT_PRIMITIVE_TO_STRING), + getSingleValueOrNull(response, myResponseLocation, ElementUtil.CONVERT_PRIMITIVE_TO_STRING), + getSingleValueOrNull(response, myResponseEtag, ElementUtil.CONVERT_PRIMITIVE_TO_STRING), + getSingleValueOrNull(response, myResponseLastModified, ElementUtil.CAST_TO_PRIMITIVE_DATE), + getSingleValueOrNull(response, myResponseOutcomeChildDef, ElementUtil.CAST_BASE_TO_RESOURCE)); + } + + @Override + public IBase toElement(BundleResponseEntryParts theParts) { + Objects.requireNonNull(theParts); + IBase entry = myEntryElementDef.newInstance(); + + setValue(entry, myFullUrlChildDef, theParts.fullUrl()); + setValue(entry, myResourceChildDef, theParts.resource()); + + // response parts + IBase response = myResponseChildContentsDef.newInstance(); + setValue(entry, myResponseChildDef, response); + + setValue(response, myResponseStatusChildDef, theParts.responseStatus()); + setValue(response, myResponseLocation, theParts.responseLocation()); + setValue(response, myResponseEtag, theParts.responseEtag()); + setValue(response, myResponseLastModified, theParts.responseLastModified()); + setValue(response, myResponseOutcomeChildDef, theParts.responseOutcome()); + + return entry; + } + } + + /** + * Build an extractor function that can be used to extract the parts of a bundle entry. + * @param theFhirContext for the mappings + * @return an extractor function on IBase objects that returns a BundleResponseEntryParts object + */ + public static Function buildPartsExtractor(FhirContext theFhirContext) { + Metadata m = new Metadata(theFhirContext); + return m::fromElement; + } + + public static Function builder(FhirContext theFhirContext) { + Metadata m = new Metadata(theFhirContext); + + return m::toElement; + } +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/PartsConverter.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/PartsConverter.java new file mode 100644 index 000000000000..807e4f815d44 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/PartsConverter.java @@ -0,0 +1,9 @@ +package ca.uhn.fhir.util.bundle; + +import org.hl7.fhir.instance.model.api.IBase; + +public interface PartsConverter { + T fromElement(IBase theElement); + + IBase toElement(T theParts); +} From 9657d8c1b94eac63a18f00140c6dedcfa400c60e Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 11 Jul 2025 18:57:19 -0400 Subject: [PATCH 17/65] working tests --- .../ca/uhn/fhir/repository/Repositories.java | 14 +- .../impl/GenericClientRepository.java | 4 +- .../repository/impl/IRepositoryLoader.java | 25 ++- .../impl/InMemoryFhirRepository.java | 183 ++++++++++++------ .../impl/InMemoryFhirRepositoryLoader.java | 16 +- .../repository/impl/UrlRepositoryFactory.java | 17 +- .../java/ca/uhn/fhir/util/BundleBuilder.java | 7 +- .../java/ca/uhn/fhir/util/BundleUtil.java | 9 + .../util/bundle/BundleResponseEntryParts.java | 10 +- .../ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java | 1 + .../fhir/jpa/dao/BaseHapiFhirResourceDao.java | 1 + ...t.java => InMemoryFhirRepositoryTest.java} | 2 +- .../bundle/BundleResponseEntryPartsTest.java | 113 +++++++++++ .../uhn/fhir/repository/IRepositoryTest.java | 84 +++++++- 14 files changed, 397 insertions(+), 89 deletions(-) rename hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/{InMemoryRepositoryTest.java => InMemoryFhirRepositoryTest.java} (87%) create mode 100644 hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/bundle/BundleResponseEntryPartsTest.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java index 4057e63c5941..c085e6e099ef 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java @@ -2,7 +2,6 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.repository.impl.GenericClientRepository; -import ca.uhn.fhir.repository.impl.InMemoryFhirRepository; import ca.uhn.fhir.repository.impl.UrlRepositoryFactory; import ca.uhn.fhir.rest.client.api.IGenericClient; import jakarta.annotation.Nonnull; @@ -12,14 +11,6 @@ * Static factory methods for creating instances of {@link IRepository}. */ public class Repositories { - public static IRepository emptyInMemoryRepository(FhirContext theFhirContext) { - return InMemoryFhirRepository.emptyRepository(theFhirContext); - } - - public static IRepository restClientRepository(IGenericClient theGenericClient) { - return new GenericClientRepository(theGenericClient); - } - /** * Private constructor to prevent instantiation. */ @@ -44,4 +35,9 @@ public static boolean isRepositoryUrl(String theBaseUrl) { public static IRepository repositoryForUrl(@Nonnull String theBaseUrl, @Nullable FhirContext theFhirContext) { return UrlRepositoryFactory.buildRepository(theBaseUrl, theFhirContext); } + + public static IRepository restClientRepository(IGenericClient theGenericClient) { + return new GenericClientRepository(theGenericClient); + } + } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java index e737869bb090..03c46ca39092 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/GenericClientRepository.java @@ -35,7 +35,7 @@ public GenericClientRepository(IGenericClient theGenericClient) { myGenericClient = theGenericClient; } - protected IGenericClient getClient() { + public IGenericClient getClient() { return myGenericClient; } @@ -218,7 +218,7 @@ protected void addHistoryPara var ctx = myGenericClient.getFhirContext(); var count = ParametersUtil.getNamedParameterValuesAsInteger(ctx, parameters, "_count"); - if (count != null && !count.isEmpty()) { + if (!count.isEmpty()) { operation.count(count.get(0)); } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/IRepositoryLoader.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/IRepositoryLoader.java index 6171c8555548..de624294118b 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/IRepositoryLoader.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/IRepositoryLoader.java @@ -10,6 +10,11 @@ /** * Service provider interface for loading repositories based on a URL. * Unstable API. Subject to change in future releases. + *

+ * Implementors will receive the url parsed into IRepositoryRequest, + * and dispatch of the subScheme property. + * E.g. The InMemoryFhirRepositoryLoader will handle URLs + * that start with fhir-repository:memory:. */ @Beta() public interface IRepositoryLoader { @@ -28,14 +33,28 @@ public interface IRepositoryLoader { * @return a repository instance */ @Nonnull - IRepository loadRepository(IRepositoryRequest theRepositoryRequest); + IRepository loadRepository(@Nonnull IRepositoryRequest theRepositoryRequest); interface IRepositoryRequest { + /** + * Get the full URL of the repository provided by the user. + * @return the URL + */ String getUrl(); + + /** + * Get the sub-scheme of the URL, e.g. "memory" for "fhir-repository:memory:details". + * @return the sub-scheme + */ String getSubScheme(); + + /** + * Get any additional details provided by the user in the URL. + * This may be a url, a unique identifier for the repository, or configuration details. + * @return the details + */ String getDetails(); + Optional getFhirContext(); } - - } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java index 91b98ba1ed7c..f9d930c78e71 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java @@ -11,16 +11,21 @@ import ca.uhn.fhir.util.BundleBuilder; import ca.uhn.fhir.util.BundleUtil; import ca.uhn.fhir.util.OperationOutcomeUtil; +import ca.uhn.fhir.util.bundle.BundleEntryParts; +import ca.uhn.fhir.util.bundle.BundleResponseEntryParts; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Multimap; import jakarta.annotation.Nonnull; import org.apache.commons.lang3.Validate; +import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.instance.model.api.IPrimitiveType; import java.util.Collection; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,6 +45,7 @@ public class InMemoryFhirRepository implements IRepository { // fixme add search with tests // fixme add sketch of extended operations + private String myBaseUrl; private final Map> resourceMap; private final FhirContext context; @@ -71,7 +77,7 @@ public static InMemoryFhirRepository fromBundleContents(FhirContext theFhirConte @Override @SuppressWarnings("unchecked") - public T read( + public synchronized T read( Class resourceType, I id, Map headers) { var lookup = lookupResource(resourceType, id); @@ -81,7 +87,7 @@ public T read( } @Override - public MethodOutcome create(T resource, Map headers) { + public synchronized MethodOutcome create(T resource, Map headers) { var resources = getResourceMapForType(resource.fhirType()); IIdType theId; @@ -92,34 +98,40 @@ public MethodOutcome create(T resource, Map MethodOutcome patch( + public synchronized MethodOutcome patch( I id, P patchParameters, Map headers) { throw new NotImplementedOperationException("The PATCH operation is not currently supported"); } @Override - public MethodOutcome update(T resource, Map headers) { + public synchronized MethodOutcome update(T resource, Map headers) { var lookup = lookupResource(resource.getClass(), resource.getIdElement()); - var outcome = new MethodOutcome(lookup.id, false); - if (!lookup.isPresent()) { - outcome.setCreated(true); - } - if (resource.fhirType().equals("SearchParameter")) { - // fixme support adding SearchParameters - // this.resourceMatcher.addCustomParameter(BundleHelper.resourceToRuntimeSearchParam(resource)); - } + boolean isCreate = !lookup.isPresent(); +// if (resource.fhirType().equals("SearchParameter")) { +// // todo support adding SearchParameters +// // this.resourceMatcher.addCustomParameter(BundleHelper.resourceToRuntimeSearchParam(resource)); +// } lookup.put(resource); + var outcome = new MethodOutcome(lookup.id, isCreate); + if (isCreate) { + outcome.setResponseStatusCode(Constants.STATUS_HTTP_201_CREATED); + } else { + outcome.setResponseStatusCode(Constants.STATUS_HTTP_200_OK); + } return outcome; } @Override - public MethodOutcome delete( + public synchronized MethodOutcome delete( Class resourceType, I id, Map headers) { var lookup = lookupResource(resourceType, id); @@ -142,13 +154,13 @@ public MethodOutcome delete( } @Override - public B search( + public synchronized B search( Class bundleType, Class resourceType, Multimap> searchParameters, Map headers) { BundleBuilder builder = new BundleBuilder(this.context); - var resourceIdMap = resourceMap.computeIfAbsent(resourceType.getSimpleName(), r -> new HashMap<>()); + var resourceIdMap = getResourceMapForType(resourceType.getTypeName()); if (searchParameters == null || searchParameters.isEmpty()) { resourceIdMap.values().forEach(builder::addCollectionEntry); @@ -204,47 +216,96 @@ public B search( } @Override - public B transaction(B transaction, Map headers) { - var version = transaction.getStructureFhirVersionEnum(); - - // @SuppressWarnings("unchecked") - // var returnBundle = (B) newBundle(version); - // BundleHelper.getEntry(transaction).forEach(e -> { - // if (BundleHelper.isEntryRequestPut(version, e)) { - // var outcome = this.update(BundleHelper.getEntryResource(version, e)); - // var location = outcome.getId().getValue(); - // BundleHelper.addEntry( - // returnBundle, - // BundleHelper.newEntryWithResponse( - // version, BundleHelper.newResponseWithLocation(version, location))); - // } else if (BundleHelper.isEntryRequestPost(version, e)) { - // var outcome = this.create(BundleHelper.getEntryResource(version, e)); - // var location = outcome.getId().getValue(); - // BundleHelper.addEntry( - // returnBundle, - // BundleHelper.newEntryWithResponse( - // version, BundleHelper.newResponseWithLocation(version, location))); - // } else if (BundleHelper.isEntryRequestDelete(version, e)) { - // if (BundleHelper.getEntryRequestId(version, e).isPresent()) { - // var resourceType = Canonicals.getResourceType( - // ((BundleEntryComponent) e).getRequest().getUrl()); - // var resourceClass = - // this.context.getResourceDefinition(resourceType).getImplementingClass(); - // var res = this.delete( - // resourceClass, - // BundleHelper.getEntryRequestId(version, e).get().withResourceType(resourceType)); - // BundleHelper.addEntry(returnBundle, BundleHelper.newEntryWithResource(res.getResource())); - // } else { - // throw new ResourceNotFoundException("Trying to delete an entry without id"); - // } - // - // } else { - // throw new NotImplementedOperationException("Transaction stub only supports PUT, POST or DELETE"); - // } - // }); - // - // return returnBundle; - return null; + public synchronized B transaction(B transaction, Map headers) { + BundleBuilder bundleBuilder = new BundleBuilder(this.context); + + bundleBuilder.setType(BundleUtil.BUNDLE_TYPE_TRANSACTION_RESPONSE); + // var version = transaction.getStructureFhirVersionEnum(); + + Function responseEntryBuilder = + BundleResponseEntryParts.builder(fhirContext()); + + IPrimitiveType now = (IPrimitiveType) + fhirContext().getElementDefinition("Instant").newInstance(); + + List entries = BundleUtil.toListOfEntries(fhirContext(), transaction); + for (BundleEntryParts e : entries) { + switch (e.getMethod()) { + case PUT -> { + MethodOutcome methodOutcome = this.update(e.getResource()); + String location = null; + if (methodOutcome.getResponseStatusCode() == Constants.STATUS_HTTP_201_CREATED) { + location = methodOutcome.getId().getValue(); + } + + BundleResponseEntryParts response = new BundleResponseEntryParts( + e.getFullUrl(), + methodOutcome.getResource(), + statusCodeToStatusLine(methodOutcome.getResponseStatusCode()), + location, + null, + now, + methodOutcome.getOperationOutcome()); + bundleBuilder.addEntry(responseEntryBuilder.apply(response)); + } + case POST -> { + var responseOutcome = this.create(e.getResource()); + var location = responseOutcome.getId().getValue(); + + BundleResponseEntryParts response = new BundleResponseEntryParts( + e.getFullUrl(), + responseOutcome.getResource(), + statusCodeToStatusLine(responseOutcome.getResponseStatusCode()), + location, + null, + now, + responseOutcome.getOperationOutcome()); + bundleBuilder.addEntry(responseEntryBuilder.apply(response)); + + } + case DELETE -> { + // Valid methods for transaction entries + } + case GET -> {} + default -> throw new NotImplementedOperationException( + "Transaction stub only supports GET, PUT, POST or DELETE"); + // fixme finish + } + // } else if (BundleHelper.isEntryRequestDelete(version, e)) { + // if (BundleHelper.getEntryRequestId(version, e).isPresent()) { + // var resourceType = Canonicals.getResourceType( + // ((BundleEntryComponent) e).getRequest().getUrl()); + // var resourceClass = + // this.context.getResourceDefinition(resourceType).getImplementingClass(); + // var res = this.delete( + // resourceClass, + // BundleHelper.getEntryRequestId(version, e).get().withResourceType(resourceType)); + // BundleHelper.addEntry(returnBundle, BundleHelper.newEntryWithResource(res.getResource())); + // } else { + // throw new ResourceNotFoundException("Trying to delete an entry without id"); + // } + // + // } else { + // throw new NotImplementedOperationException("Transaction stub only supports PUT, POST or DELETE"); + // } + // }); + // + // return returnBundle; + } + + return bundleBuilder.getBundleTyped(); + } + + // fixme find a home for this + public static String statusCodeToStatusLine(int theResponseStatusCode) { + return switch (theResponseStatusCode) { + case Constants.STATUS_HTTP_200_OK -> "200 OK"; + case Constants.STATUS_HTTP_201_CREATED -> "201 Created"; + case Constants.STATUS_HTTP_409_CONFLICT -> "409 Conflict"; + case Constants.STATUS_HTTP_204_NO_CONTENT -> "204 No Content"; + case Constants.STATUS_HTTP_404_NOT_FOUND -> "404 Not Found"; + default -> throw new IllegalArgumentException("Unsupported response status code: " + theResponseStatusCode); + }; } /** @@ -256,6 +317,14 @@ public Map getResourceMapForType(String resourceTypeName return resourceMap.computeIfAbsent(resourceTypeName, x -> new HashMap<>()); } + public String getBaseUrl() { + return myBaseUrl; + } + + public void setBaseUrl(String theBaseUrl) { + myBaseUrl = theBaseUrl; + } + /** * Abstract "pointer" to a resource id in the repository. * @param resources the map of resources for a specific type diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java index 972eebf8b908..28a60665a5c5 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java @@ -16,12 +16,18 @@ public InMemoryFhirRepositoryLoader() { @Nonnull @Override - public IRepository loadRepository(IRepositoryRequest theRepositoryRequest) { - FhirContext context = theRepositoryRequest.getFhirContext() - // fixme hapi-code - .orElseThrow(()-> new IllegalArgumentException("The :memory: FHIR repository requires a FhirContext.")); + public IRepository loadRepository(@Nonnull IRepositoryRequest theRepositoryRequest) { + FhirContext context = theRepositoryRequest + .getFhirContext() + // fixme hapi-code + .orElseThrow( + () -> new IllegalArgumentException("The :memory: FHIR repository requires a FhirContext.")); String memoryKey = theRepositoryRequest.getDetails(); - return ourRepositories.computeIfAbsent(memoryKey, k->InMemoryFhirRepository.emptyRepository(context)); + return ourRepositories.computeIfAbsent(memoryKey, k -> { + InMemoryFhirRepository inMemoryFhirRepository = InMemoryFhirRepository.emptyRepository(context); + inMemoryFhirRepository.setBaseUrl(theRepositoryRequest.getUrl()); + return inMemoryFhirRepository; + }); } } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java index 2057c76835c2..51c40cda4985 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java @@ -20,12 +20,13 @@ public class UrlRepositoryFactory { static final Pattern ourUrlPattern = Pattern.compile("^fhir-repository:([A-Za-z]+):(.*)"); public static boolean isRepositoryUrl(String theBaseUrl) { - return theBaseUrl != null && - theBaseUrl.startsWith(FHIR_REPOSITORY_URL_SCHEME) && - ourUrlPattern.matcher(theBaseUrl).matches(); + return theBaseUrl != null + && theBaseUrl.startsWith(FHIR_REPOSITORY_URL_SCHEME) + && ourUrlPattern.matcher(theBaseUrl).matches(); } - protected record RepositoryRequest(String url, String subScheme, String details, FhirContext fhirContext) implements IRepositoryLoader.IRepositoryRequest { + protected record RepositoryRequest(String url, String subScheme, String details, FhirContext fhirContext) + implements IRepositoryLoader.IRepositoryRequest { @Override public String getUrl() { return url; @@ -54,7 +55,8 @@ public static IRepository buildRepository(@Nonnull String theBaseUrl, @Nullable if (!isRepositoryUrl(theBaseUrl)) { // fixme hapi-code - throw new IllegalArgumentException(Msg.code(99997) + "Base URL is not a valid repository URL: " + theBaseUrl); + throw new IllegalArgumentException( + Msg.code(99997) + "Base URL is not a valid repository URL: " + theBaseUrl); } ServiceLoader load = ServiceLoader.load(IRepositoryLoader.class); @@ -65,8 +67,9 @@ public static IRepository buildRepository(@Nonnull String theBaseUrl, @Nullable return nextLoader.loadRepository(request); } } - // fixme hapi-code - throw new IllegalArgumentException(Msg.code(99999) + "Unable to find a repository loader for URL: " + theBaseUrl); + // fixme hapi-code + throw new IllegalArgumentException( + Msg.code(99999) + "Unable to find a repository loader for URL: " + theBaseUrl); } @Nonnull diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleBuilder.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleBuilder.java index 5f951513c3fb..a39ab3b9acec 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleBuilder.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleBuilder.java @@ -495,10 +495,15 @@ public void addMessageEntry(IBaseResource theResource) { */ public IBase addEntry() { IBase entry = myEntryDef.newInstance(); - myEntryChild.getMutator().addValue(myBundle, entry); + addEntry(entry); return entry; } + public IBase addEntry(IBase theEntry) { + myEntryChild.getMutator().addValue(myBundle, theEntry); + return theEntry; + } + /** * Creates new search instance for the specified entry. * Note that this method does not work for DSTU2 model classes, it will only work diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java index 5bdc3b991ab3..c6b110f56836 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/BundleUtil.java @@ -35,6 +35,7 @@ import ca.uhn.fhir.util.bundle.BundleEntryParts; import ca.uhn.fhir.util.bundle.EntryListAccumulator; import ca.uhn.fhir.util.bundle.ModifiableBundleEntry; +import ca.uhn.fhir.util.bundle.PartsConverter; import ca.uhn.fhir.util.bundle.SearchBundleEntryParts; import com.google.common.collect.Sets; import jakarta.annotation.Nonnull; @@ -302,6 +303,14 @@ public static List toListOfEntries(FhirContext theContext, IBa return entryListAccumulator.getList(); } + public static List toListOfEntries( + FhirContext theContext, IBaseBundle theBundle, PartsConverter partsConverter) { + RuntimeResourceDefinition bundleDef = theContext.getResourceDefinition(theBundle); + BaseRuntimeChildDefinition entryChildDef = bundleDef.getChildByName("entry"); + List entries = entryChildDef.getAccessor().getValues(theBundle); + return entries.stream().map(partsConverter::fromElement).toList(); + } + /** * Function which will do an in-place sort of a bundles' entries, to the correct processing order, which is: * 1. Deletes diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleResponseEntryParts.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleResponseEntryParts.java index 2be239f478ba..68d2e9eccd89 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleResponseEntryParts.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/util/bundle/BundleResponseEntryParts.java @@ -4,6 +4,7 @@ import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition; import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.util.ElementUtil; +import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.IBaseResource; @@ -110,12 +111,17 @@ public IBase toElement(BundleResponseEntryParts theParts) { * @return an extractor function on IBase objects that returns a BundleResponseEntryParts object */ public static Function buildPartsExtractor(FhirContext theFhirContext) { - Metadata m = new Metadata(theFhirContext); + PartsConverter m = getConverter(theFhirContext); return m::fromElement; } + @Nonnull + public static PartsConverter getConverter(FhirContext theFhirContext) { + return new Metadata(theFhirContext); + } + public static Function builder(FhirContext theFhirContext) { - Metadata m = new Metadata(theFhirContext); + PartsConverter m = getConverter(theFhirContext); return m::toElement; } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java index 44ada0efc154..cf5c204444de 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java @@ -1488,6 +1488,7 @@ public DaoMethodOutcome updateInternal( DaoMethodOutcome outcome = toMethodOutcome( theRequestDetails, savedEntity, theResource, theMatchUrl, theOperationType) .setCreated(wasDeleted); + outcome.setResponseStatusCode(Constants.STATUS_HTTP_200_OK); if (!thePerformIndexing) { IIdType id = getContext().getVersion().newIdType(); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java index 90c18ca95b00..b5f6f8583e8c 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java @@ -645,6 +645,7 @@ private DaoMethodOutcome doCreateForPostOrPut( DaoMethodOutcome outcome = toMethodOutcome(theRequest, entity, theResource, theMatchUrl, theOperationType) .setCreated(true); + outcome.setResponseStatusCode(Constants.STATUS_HTTP_201_CREATED); if (!thePerformIndexing) { outcome.setId(theResource.getIdElement()); diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/InMemoryRepositoryTest.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryTest.java similarity index 87% rename from hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/InMemoryRepositoryTest.java rename to hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryTest.java index 75998b44af0c..1a7357624d79 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/InMemoryRepositoryTest.java +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryTest.java @@ -3,7 +3,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.repository.IRepositoryTest; -public class InMemoryRepositoryTest implements IRepositoryTest { +public class InMemoryFhirRepositoryTest implements IRepositoryTest { FhirContext myFhirContext = FhirContext.forR4(); InMemoryFhirRepository myRepository = InMemoryFhirRepository.emptyRepository(myFhirContext); diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/bundle/BundleResponseEntryPartsTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/bundle/BundleResponseEntryPartsTest.java new file mode 100644 index 000000000000..510be4b9fd97 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/util/bundle/BundleResponseEntryPartsTest.java @@ -0,0 +1,113 @@ +package ca.uhn.fhir.util.bundle; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.parser.IParser; +import ca.uhn.fhir.util.FhirTerser; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.InstantType; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.Test; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class BundleResponseEntryPartsTest { + + public static final String ENTRY_JSON = """ + { + "fullUrl": "https://example.org/fhir/Patient/12423", + "resource": { "resourceType": "Patient", "active": true}, + "response": { + "status": "201 Created", + "location": "Patient/12423/_history/1", + "etag": "W/\\"1\\"", + "lastModified": "2014-08-18T01:43:33Z", + "outcome": { "resourceType": "OperationOutcome" } + } + } + """; + FhirContext myFhirContext = FhirContext.forR4Cached(); + IParser myParser = myFhirContext.newJsonParser().setPrettyPrint(true); + + @Test + void testExtractor() { + // given + Bundle.BundleEntryComponent entry = new Bundle.BundleEntryComponent(); + + myParser.parseInto(ENTRY_JSON, entry); + + // when + BundleResponseEntryParts parts = BundleResponseEntryParts.buildPartsExtractor(myFhirContext).apply(entry); + + // then + assertThat(parts.fullUrl()).isEqualTo("https://example.org/fhir/Patient/12423"); + assertThat(parts.resource()).isNotNull(); + assertThat(parts.resource().fhirType()).isEqualTo("Patient"); + assertThat(parts.responseOutcome()).isNotNull(); + assertThat(parts.responseOutcome().fhirType()).isEqualTo("OperationOutcome"); + assertThat(parts.responseStatus()).isEqualTo("201 Created"); + assertThat(parts.responseLocation()).isEqualTo("Patient/12423/_history/1"); + assertThat(parts.responseEtag()).isEqualTo("W/\"1\""); + assertThat(parts.responseLastModified().getValueAsString()).isEqualTo("2014-08-18T01:43:33Z"); + } + + @Test + void testBuilder() { + Patient patient = new Patient(); + patient.setActive(true); + OperationOutcome outcome = new OperationOutcome(); + BundleResponseEntryParts parts = new BundleResponseEntryParts( + "https://example.org/fhir/Patient/12423", + patient, + "201 Created", + "Patient/12423/_history/1", + "W/\"1\"", + new InstantType("2014-08-18T01:43:33Z"), + outcome // responseOutcome + ); + Function builder = BundleResponseEntryParts.builder(myFhirContext); + + // when + IBase element = builder.apply(parts); + + // then + assertThat(element).isNotNull(); + FhirTerser terser = myFhirContext.newTerser(); + assertEquals(patient, terser.getSingleValueOrNull(element, "resource")); + assertEquals("https://example.org/fhir/Patient/12423", terser.getSinglePrimitiveValueOrNull(element, "fullUrl")); + assertEquals("201 Created", terser.getSinglePrimitiveValueOrNull(element, "response.status")); + assertEquals("Patient/12423/_history/1", terser.getSinglePrimitiveValueOrNull(element, "response.location")); + assertEquals("W/\"1\"", terser.getSinglePrimitiveValueOrNull(element, "response.etag")); + assertEquals("2014-08-18T01:43:33Z", terser.getSinglePrimitiveValueOrNull(element, "response.lastModified")); + assertEquals(outcome, terser.getSingleValueOrNull(element, "response.outcome")); + } + + + + @Test + void testBuilder_nullsProducesEmptyElement() { + BundleResponseEntryParts parts = new BundleResponseEntryParts(null,null,null,null,null,null,null); + Function builder = BundleResponseEntryParts.builder(myFhirContext); + + // when + IBase element = builder.apply(parts); + + // then + assertThat(element).isNotNull(); + FhirTerser terser = myFhirContext.newTerser(); + assertNull(terser.getSingleValueOrNull(element, "resource")); + assertNull(terser.getSinglePrimitiveValueOrNull(element, "fullUrl")); + assertNull(terser.getSinglePrimitiveValueOrNull(element, "response")); + assertNull(terser.getSinglePrimitiveValueOrNull(element, "response.status")); + assertNull(terser.getSinglePrimitiveValueOrNull(element, "response.location")); + assertNull(terser.getSinglePrimitiveValueOrNull(element, "response.etag")); + assertNull(terser.getSinglePrimitiveValueOrNull(element, "response.lastModified")); + assertNull(terser.getSingleValueOrNull(element, "response.outcome")); + } + +} diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java index 29e1cba0e925..11889611ae1a 100644 --- a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -2,18 +2,24 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.model.primitive.IdDt; +import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException; import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; import ca.uhn.fhir.test.utilities.ITestDataBuilder; import ca.uhn.fhir.test.utilities.RepositoryTestDataBuilder; +import ca.uhn.fhir.util.BundleBuilder; +import ca.uhn.fhir.util.BundleUtil; import ca.uhn.fhir.util.FhirTerser; import ca.uhn.fhir.util.ParametersUtil; +import ca.uhn.fhir.util.bundle.BundleResponseEntryParts; +import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.r4.model.DateType; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIf; import org.slf4j.Logger; @@ -21,6 +27,9 @@ import javax.annotation.Nonnull; +import java.util.List; + +import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_201_CREATED; import static ca.uhn.fhir.util.ParametersUtil.addParameterToParameters; import static ca.uhn.fhir.util.ParametersUtil.addPart; import static ca.uhn.fhir.util.ParametersUtil.addPartCode; @@ -28,7 +37,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; /** Generic test of repository functionality */ -@SuppressWarnings({"java:S5960" // this is a test jar +@SuppressWarnings({ + "java:S5960" // this is a test jar + , + "java:S1199" // this is a test jar }) public interface IRepositoryTest { Logger ourLog = LoggerFactory.getLogger(IRepositoryTest.class); @@ -83,12 +95,16 @@ default void testCreate_update_readById_verifyUpdatedContents() { // when - create MethodOutcome createOutcome = repository.create(patient); IIdType patientId = createOutcome.getId().toVersionless(); + assertThat(createOutcome.getCreated()).isTrue(); + assertThat(createOutcome.getResponseStatusCode()).isEqualTo(STATUS_HTTP_201_CREATED); + assertThat(createOutcome.getResource()).isNotNull(); // update with different birthdate var updatedPatient = b.buildPatient(b.withId(patientId), b.withBirthdate(BIRTHDATE2)); var updateOutcome = repository.update(updatedPatient); assertThat(updateOutcome.getId().toVersionless().getValueAsString()).isEqualTo(patientId.getValueAsString()); assertThat(updateOutcome.getCreated()).isFalse(); + assertThat(updateOutcome.getResponseStatusCode()).isEqualTo(Constants.STATUS_HTTP_200_OK); // read IBaseResource read = repository.read(patient.getClass(), patientId); @@ -101,6 +117,23 @@ default void testCreate_update_readById_verifyUpdatedContents() { .isEqualTo(BIRTHDATE2); } + @Test + default void testCreate_clientAssignedId_outcome() { + // given + var b = getTestDataBuilder(); + var patient = b.buildPatient(b.withId("pat123"), b.withBirthdate(BIRTHDATE1)); + IRepository repository = getRepository(); + + // when + var updateOutcome = repository.update(patient); + + // then + assertThat(updateOutcome.getId().toUnqualifiedVersionless().getValueAsString()) + .isEqualTo("Patient/pat123"); + assertThat(updateOutcome.getCreated()).isTrue(); + assertThat(updateOutcome.getResponseStatusCode()).isEqualTo(STATUS_HTTP_201_CREATED); + } + @Test default void testCreate_delete_readById_throwsException() { // given a patient resource @@ -135,7 +168,7 @@ default void testDelete_noCreate_returnsOutcome() { } default boolean isPatchSupported() { - // todo this should really come from the repository capabilities + // SOMEDAY: ideally this would come from the repository capabilities return true; } @@ -168,6 +201,53 @@ default void testPatch_changesValue() { .isEqualTo(BIRTHDATE2); } + @Test + default void testSimpleTxBundle() { + // given + var repository = getRepository(); + var fhirContext = getRepository().fhirContext(); + var b = getTestDataBuilder(); + var patient = b.buildPatient(); + var patientWithId = b.buildPatient(b.withId("abc")); + BundleBuilder bundleBuilder = new BundleBuilder(fhirContext); + + bundleBuilder.addTransactionCreateEntry(patient, "urn:uuid:0198234701923"); + bundleBuilder.addTransactionUpdateEntry(patientWithId); + IBaseBundle bundle = bundleBuilder.getBundle(); + + // when + IBaseBundle resultBundle = repository.transaction(bundle); + + // then + assertThat(resultBundle).isNotNull(); + assertThat(BundleUtil.getBundleType(fhirContext, resultBundle)) + .isEqualTo(BundleUtil.BUNDLE_TYPE_TRANSACTION_RESPONSE); + + List bundleResponseEntryParts = BundleUtil.toListOfEntries( + fhirContext, resultBundle, BundleResponseEntryParts.getConverter(fhirContext)); + assertThat(bundleResponseEntryParts).hasSize(2); + { + BundleResponseEntryParts createResponseEntry = bundleResponseEntryParts.get(0); + + assertThat(createResponseEntry.fullUrl()) + .satisfiesAnyOf(Assertions::assertNull, fullUrl -> assertThat(fullUrl) + .isEqualTo("urn:uuid:0198234701923")); + assertThat(createResponseEntry.responseStatus()).startsWith("" + STATUS_HTTP_201_CREATED); + assertThat(createResponseEntry.responseLocation()).isNotBlank(); + } + { + BundleResponseEntryParts updateResponseEntry = bundleResponseEntryParts.get(1); + + assertThat(updateResponseEntry.fullUrl()) + .satisfiesAnyOf(Assertions::assertNull, fullUrl -> assertThat(fullUrl) + .contains("Patient/abc")); + assertThat(updateResponseEntry.responseStatus()).startsWith("" + STATUS_HTTP_201_CREATED); + assertThat(updateResponseEntry.responseLocation()).isNotBlank(); + } + } + + // fixme add test for search all of type. + /** Implementors of this test template must provide a RepositoryTestSupport instance */ RepositoryTestSupport getRepositoryTestSupport(); From b19426354940dda09ccd9a6a1b33d8e36a4a17c2 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 11 Jul 2025 20:03:57 -0400 Subject: [PATCH 18/65] Add sketch search to memory repository --- .../impl/InMemoryFhirRepository.java | 102 ++--------------- .../NaiveRepositoryTransactionProcessor.java | 103 ++++++++++++++++++ .../repository/impl/UrlRepositoryFactory.java | 2 +- .../impl/UrlRepositoryFactoryTest.java | 1 + .../fhir/jpa/dao/BaseHapiFhirResourceDao.java | 11 +- .../fhir/jpa/dao/HapiFhirRepositoryTest.java | 5 + .../uhn/fhir/repository/IRepositoryTest.java | 55 ++++++++-- 7 files changed, 176 insertions(+), 103 deletions(-) create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/NaiveRepositoryTransactionProcessor.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java index f9d930c78e71..cd09bfa6ee04 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java @@ -11,21 +11,16 @@ import ca.uhn.fhir.util.BundleBuilder; import ca.uhn.fhir.util.BundleUtil; import ca.uhn.fhir.util.OperationOutcomeUtil; -import ca.uhn.fhir.util.bundle.BundleEntryParts; -import ca.uhn.fhir.util.bundle.BundleResponseEntryParts; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Multimap; import jakarta.annotation.Nonnull; import org.apache.commons.lang3.Validate; -import org.hl7.fhir.instance.model.api.IBase; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; -import org.hl7.fhir.instance.model.api.IPrimitiveType; import java.util.Collection; -import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,10 +35,10 @@ * Based on org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository. * This repository stores resources in memory * and provides basic CRUD operations, search, and transaction support. + * SOMEDAY add support for extended operations and custom search parameters. */ public class InMemoryFhirRepository implements IRepository { - // fixme add search with tests // fixme add sketch of extended operations private String myBaseUrl; private final Map> resourceMap; @@ -115,10 +110,6 @@ public synchronized MethodOutcome update(T resource, M var lookup = lookupResource(resource.getClass(), resource.getIdElement()); boolean isCreate = !lookup.isPresent(); -// if (resource.fhirType().equals("SearchParameter")) { -// // todo support adding SearchParameters -// // this.resourceMatcher.addCustomParameter(BundleHelper.resourceToRuntimeSearchParam(resource)); -// } lookup.put(resource); var outcome = new MethodOutcome(lookup.id, isCreate); if (isCreate) { @@ -138,7 +129,9 @@ public synchronized MethodOutcome d if (lookup.isPresent()) { var resource = lookup.getResourceOrThrow404(); lookup.remove(); - return new MethodOutcome(id, false).setResource(resource); + MethodOutcome methodOutcome = new MethodOutcome(id, false).setResource(resource); + methodOutcome.setResponseStatusCode(Constants.STATUS_HTTP_204_NO_CONTENT); + return methodOutcome; } else { var oo = OperationOutcomeUtil.createOperationOutcome( OperationOutcomeUtil.OO_SEVERITY_WARN, @@ -160,7 +153,9 @@ public synchronized B search( Multimap> searchParameters, Map headers) { BundleBuilder builder = new BundleBuilder(this.context); - var resourceIdMap = getResourceMapForType(resourceType.getTypeName()); + + String searchType = context.getResourceType(resourceType); + var resourceIdMap = getResourceMapForType(searchType); if (searchParameters == null || searchParameters.isEmpty()) { resourceIdMap.values().forEach(builder::addCollectionEntry); @@ -170,7 +165,7 @@ public synchronized B search( } Collection candidates = resourceIdMap.values(); - // fixme + // todo implement more search parameters // if (searchParameters.containsKey("_id")) { // // We are consuming the _id parameter in this if statement // var idQueries = searchParameters.get("_id"); @@ -217,86 +212,11 @@ public synchronized B search( @Override public synchronized B transaction(B transaction, Map headers) { - BundleBuilder bundleBuilder = new BundleBuilder(this.context); - - bundleBuilder.setType(BundleUtil.BUNDLE_TYPE_TRANSACTION_RESPONSE); - // var version = transaction.getStructureFhirVersionEnum(); - - Function responseEntryBuilder = - BundleResponseEntryParts.builder(fhirContext()); - - IPrimitiveType now = (IPrimitiveType) - fhirContext().getElementDefinition("Instant").newInstance(); - - List entries = BundleUtil.toListOfEntries(fhirContext(), transaction); - for (BundleEntryParts e : entries) { - switch (e.getMethod()) { - case PUT -> { - MethodOutcome methodOutcome = this.update(e.getResource()); - String location = null; - if (methodOutcome.getResponseStatusCode() == Constants.STATUS_HTTP_201_CREATED) { - location = methodOutcome.getId().getValue(); - } - - BundleResponseEntryParts response = new BundleResponseEntryParts( - e.getFullUrl(), - methodOutcome.getResource(), - statusCodeToStatusLine(methodOutcome.getResponseStatusCode()), - location, - null, - now, - methodOutcome.getOperationOutcome()); - bundleBuilder.addEntry(responseEntryBuilder.apply(response)); - } - case POST -> { - var responseOutcome = this.create(e.getResource()); - var location = responseOutcome.getId().getValue(); - - BundleResponseEntryParts response = new BundleResponseEntryParts( - e.getFullUrl(), - responseOutcome.getResource(), - statusCodeToStatusLine(responseOutcome.getResponseStatusCode()), - location, - null, - now, - responseOutcome.getOperationOutcome()); - bundleBuilder.addEntry(responseEntryBuilder.apply(response)); - - } - case DELETE -> { - // Valid methods for transaction entries - } - case GET -> {} - default -> throw new NotImplementedOperationException( - "Transaction stub only supports GET, PUT, POST or DELETE"); - // fixme finish - } - // } else if (BundleHelper.isEntryRequestDelete(version, e)) { - // if (BundleHelper.getEntryRequestId(version, e).isPresent()) { - // var resourceType = Canonicals.getResourceType( - // ((BundleEntryComponent) e).getRequest().getUrl()); - // var resourceClass = - // this.context.getResourceDefinition(resourceType).getImplementingClass(); - // var res = this.delete( - // resourceClass, - // BundleHelper.getEntryRequestId(version, e).get().withResourceType(resourceType)); - // BundleHelper.addEntry(returnBundle, BundleHelper.newEntryWithResource(res.getResource())); - // } else { - // throw new ResourceNotFoundException("Trying to delete an entry without id"); - // } - // - // } else { - // throw new NotImplementedOperationException("Transaction stub only supports PUT, POST or DELETE"); - // } - // }); - // - // return returnBundle; - } - - return bundleBuilder.getBundleTyped(); + NaiveRepositoryTransactionProcessor transactionProcessor = new NaiveRepositoryTransactionProcessor(this); + return transactionProcessor.processTransaction(transaction, headers); } - // fixme find a home for this + // SOMEDAY find a home for this public static String statusCodeToStatusLine(int theResponseStatusCode) { return switch (theResponseStatusCode) { case Constants.STATUS_HTTP_200_OK -> "200 OK"; diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/NaiveRepositoryTransactionProcessor.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/NaiveRepositoryTransactionProcessor.java new file mode 100644 index 000000000000..f7f2f6a649a0 --- /dev/null +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/NaiveRepositoryTransactionProcessor.java @@ -0,0 +1,103 @@ +package ca.uhn.fhir.repository.impl; + +import ca.uhn.fhir.model.primitive.IdDt; +import ca.uhn.fhir.repository.IRepository; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.server.exceptions.NotImplementedOperationException; +import ca.uhn.fhir.util.BundleBuilder; +import ca.uhn.fhir.util.BundleUtil; +import ca.uhn.fhir.util.bundle.BundleEntryParts; +import ca.uhn.fhir.util.bundle.BundleResponseEntryParts; +import org.apache.commons.lang3.Validate; +import org.hl7.fhir.instance.model.api.IBase; +import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.instance.model.api.IPrimitiveType; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Implements a naive transaction processor for repositories in terms of crud primitives. + * SOMEDAY implement GET, PATCH, and other methods as needed. + * SOMEDAY order entries + */ +public class NaiveRepositoryTransactionProcessor { + + IRepository myRepository; + public NaiveRepositoryTransactionProcessor(IRepository theRepository) { + myRepository = theRepository; + } + + B processTransaction(B transaction, Map theHeaders) { + BundleBuilder bundleBuilder = new BundleBuilder(myRepository.fhirContext()); + + bundleBuilder.setType(BundleUtil.BUNDLE_TYPE_TRANSACTION_RESPONSE); + + Function responseEntryBuilder = + BundleResponseEntryParts.builder(myRepository.fhirContext()); + + IPrimitiveType now = (IPrimitiveType) + myRepository.fhirContext().getElementDefinition("Instant").newInstance(); + + List entries = BundleUtil.toListOfEntries(myRepository.fhirContext(), transaction); + for (BundleEntryParts e : entries) { + switch (e.getMethod()) { + case PUT -> { + MethodOutcome methodOutcome = myRepository.update(e.getResource()); + String location = null; + if (methodOutcome.getResponseStatusCode() == Constants.STATUS_HTTP_201_CREATED) { + location = methodOutcome.getId().getValue(); + } + + BundleResponseEntryParts response = new BundleResponseEntryParts( + e.getFullUrl(), + methodOutcome.getResource(), + InMemoryFhirRepository.statusCodeToStatusLine(methodOutcome.getResponseStatusCode()), + location, + null, + now, + methodOutcome.getOperationOutcome()); + bundleBuilder.addEntry(responseEntryBuilder.apply(response)); + } + case POST -> { + var responseOutcome = myRepository.create(e.getResource()); + var location = responseOutcome.getId().getValue(); + + BundleResponseEntryParts response = new BundleResponseEntryParts( + e.getFullUrl(), + responseOutcome.getResource(), + InMemoryFhirRepository.statusCodeToStatusLine(responseOutcome.getResponseStatusCode()), + location, + null, + now, + responseOutcome.getOperationOutcome()); + bundleBuilder.addEntry(responseEntryBuilder.apply(response)); + + } + case DELETE -> { + IdDt idDt = new IdDt(e.getUrl()); + String resourceType = idDt.getResourceType(); + Validate.notBlank(resourceType, "Missing resource type for deletion %s", e.getUrl()); + + MethodOutcome responseOutcome = myRepository.delete(myRepository.fhirContext().getResourceDefinition(resourceType).getImplementingClass(), idDt); + BundleResponseEntryParts response = new BundleResponseEntryParts( + e.getFullUrl(), + null, + InMemoryFhirRepository.statusCodeToStatusLine(responseOutcome.getResponseStatusCode()), + null, + null, + now, + responseOutcome.getOperationOutcome()); + bundleBuilder.addEntry(responseEntryBuilder.apply(response)); + } + default -> throw new NotImplementedOperationException( + "Transaction stub only supports PUT, POST or DELETE"); + } + } + + return bundleBuilder.getBundleTyped(); + } +} diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java index 51c40cda4985..bd69a7014708 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java @@ -17,7 +17,7 @@ public class UrlRepositoryFactory { private static final Logger ourLog = IRepository.ourLog; public static final String FHIR_REPOSITORY_URL_SCHEME = "fhir-repository:"; - static final Pattern ourUrlPattern = Pattern.compile("^fhir-repository:([A-Za-z]+):(.*)"); + static final Pattern ourUrlPattern = Pattern.compile("^fhir-repository:([A-Za-z-]+):(.*)"); public static boolean isRepositoryUrl(String theBaseUrl) { return theBaseUrl != null diff --git a/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactoryTest.java b/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactoryTest.java index b2bd53692b99..53541c8cdbeb 100644 --- a/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactoryTest.java +++ b/hapi-fhir-base/src/test/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactoryTest.java @@ -22,6 +22,7 @@ class UrlRepositoryFactoryTest { false, fhir-repository:provider/config false, fhir-repository:provider true, fhir-repository:provider: + true, fhir-repository:another-provider: """) void testIsUrl(boolean theExpectedResult, String theUrl) { assertEquals(theExpectedResult, UrlRepositoryFactory.isRepositoryUrl(theUrl)); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java index b5f6f8583e8c..5503c2e2cd4d 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java @@ -772,10 +772,12 @@ public DaoMethodOutcome delete( // if not found, return an outcome anyway. // Because no object actually existed, we'll // just set the id and nothing else - return createMethodOutcomeForResourceId( - theId.getValue(), - MESSAGE_KEY_DELETE_RESOURCE_NOT_EXISTING, - StorageResponseCodeEnum.SUCCESSFUL_DELETE_NOT_FOUND); + DaoMethodOutcome daoMethodOutcome = createMethodOutcomeForResourceId( + theId.getValue(), + MESSAGE_KEY_DELETE_RESOURCE_NOT_EXISTING, + StorageResponseCodeEnum.SUCCESSFUL_DELETE_NOT_FOUND); + daoMethodOutcome.setResponseStatusCode(Constants.STATUS_HTTP_404_NOT_FOUND); + return daoMethodOutcome; } if (theId.hasVersionIdPart() && Long.parseLong(theId.getVersionIdPart()) != entity.getVersion()) { @@ -796,6 +798,7 @@ public DaoMethodOutcome delete( // used to exist, so we'll set the persistent id outcome.setPersistentId(persistentId); outcome.setEntity(entity); + outcome.setResponseStatusCode(Constants.STATUS_HTTP_410_GONE); return outcome; } diff --git a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java index 999972d3a305..369127ff67b2 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java +++ b/hapi-fhir-jpaserver-test-utilities/src/test/java/ca/uhn/fhir/jpa/dao/HapiFhirRepositoryTest.java @@ -24,4 +24,9 @@ public void afterResetDao() { public RepositoryTestSupport getRepositoryTestSupport() { return new RepositoryTestSupport(new HapiFhirRepository(myDaoRegistry, mySrd, null)); } + + @Override + public boolean isSearchSupported() { + return false; + } } diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java index 11889611ae1a..671faddce414 100644 --- a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -14,6 +14,7 @@ import ca.uhn.fhir.util.FhirTerser; import ca.uhn.fhir.util.ParametersUtil; import ca.uhn.fhir.util.bundle.BundleResponseEntryParts; +import ca.uhn.fhir.util.bundle.SearchBundleEntryParts; import org.hl7.fhir.instance.model.api.IBaseBundle; import org.hl7.fhir.instance.model.api.IBaseParameters; import org.hl7.fhir.instance.model.api.IBaseResource; @@ -26,10 +27,11 @@ import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; - import java.util.List; +import java.util.Map; import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_201_CREATED; +import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_204_NO_CONTENT; import static ca.uhn.fhir.util.ParametersUtil.addParameterToParameters; import static ca.uhn.fhir.util.ParametersUtil.addPart; import static ca.uhn.fhir.util.ParametersUtil.addPartCode; @@ -47,6 +49,16 @@ public interface IRepositoryTest { String BIRTHDATE1 = "1970-02-14"; String BIRTHDATE2 = "1975-01-01"; + default boolean isPatchSupported() { + // SOMEDAY: ideally this would come from the repository capabilities + return true; + } + + default boolean isSearchSupported() { + // SOMEDAY: ideally this would come from the repository capabilities + return true; + } + @Test default void testCreate_readById_contentsPersisted() { // given @@ -165,12 +177,9 @@ default void testDelete_noCreate_returnsOutcome() { // then assertThat(outcome).isNotNull(); + assertThat(outcome.getResponseStatusCode()).isEqualTo(Constants.STATUS_HTTP_404_NOT_FOUND); } - default boolean isPatchSupported() { - // SOMEDAY: ideally this would come from the repository capabilities - return true; - } @Test @EnabledIf("isPatchSupported") @@ -209,10 +218,15 @@ default void testSimpleTxBundle() { var b = getTestDataBuilder(); var patient = b.buildPatient(); var patientWithId = b.buildPatient(b.withId("abc")); + var patientToDelete = b.buildPatient(); + + var deletePatientId = repository.create(patientToDelete).getId().toUnqualifiedVersionless(); BundleBuilder bundleBuilder = new BundleBuilder(fhirContext); bundleBuilder.addTransactionCreateEntry(patient, "urn:uuid:0198234701923"); bundleBuilder.addTransactionUpdateEntry(patientWithId); + bundleBuilder.addTransactionDeleteEntry(deletePatientId); + IBaseBundle bundle = bundleBuilder.getBundle(); // when @@ -225,7 +239,7 @@ default void testSimpleTxBundle() { List bundleResponseEntryParts = BundleUtil.toListOfEntries( fhirContext, resultBundle, BundleResponseEntryParts.getConverter(fhirContext)); - assertThat(bundleResponseEntryParts).hasSize(2); + assertThat(bundleResponseEntryParts).hasSize(3); { BundleResponseEntryParts createResponseEntry = bundleResponseEntryParts.get(0); @@ -244,9 +258,36 @@ default void testSimpleTxBundle() { assertThat(updateResponseEntry.responseStatus()).startsWith("" + STATUS_HTTP_201_CREATED); assertThat(updateResponseEntry.responseLocation()).isNotBlank(); } + { + BundleResponseEntryParts entry = bundleResponseEntryParts.get(2); + + assertThat(entry.fullUrl()) + .satisfiesAnyOf(Assertions::assertNull, fullUrl -> assertThat(fullUrl) + .contains(deletePatientId.getIdPart())); + assertThat(entry.responseStatus()).startsWith("" + STATUS_HTTP_204_NO_CONTENT); + } + } + + @EnabledIf("isSearchSupported") + @Test + default void testSearchAllOfType() { + // given + FhirContext context = getRepository().fhirContext(); + var repository = getRepository(); + var b = getTestDataBuilder(); + var patientClass = getTestDataBuilder().buildPatient().getClass(); + b.createPatient(b.withId("abc")); + b.createPatient(b.withId("def")); + IBaseBundle bundle = new BundleBuilder(context).getBundle(); + + // when + IBaseBundle searchResult = repository.search(bundle.getClass(), patientClass, Map.of()); + + // then + List entries = BundleUtil.getSearchBundleEntryParts(context, searchResult); + assertThat(entries).hasSize(2); } - // fixme add test for search all of type. /** Implementors of this test template must provide a RepositoryTestSupport instance */ RepositoryTestSupport getRepositoryTestSupport(); From 4ade296d229c4d804c0db8c825c09f901230590e Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 11 Jul 2025 20:13:31 -0400 Subject: [PATCH 19/65] Cleanup --- .../ca/uhn/fhir/repository/Repositories.java | 1 - .../NaiveRepositoryTransactionProcessor.java | 25 +++++++++++-------- .../impl/SchemeBasedFhirRepositoryLoader.java | 17 ++++++++++--- .../fhir/jpa/dao/BaseHapiFhirResourceDao.java | 6 ++--- .../uhn/fhir/repository/IRepositoryTest.java | 11 +++----- 5 files changed, 35 insertions(+), 25 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java index c085e6e099ef..dc5f13cc692a 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/Repositories.java @@ -39,5 +39,4 @@ public static IRepository repositoryForUrl(@Nonnull String theBaseUrl, @Nullable public static IRepository restClientRepository(IGenericClient theGenericClient) { return new GenericClientRepository(theGenericClient); } - } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/NaiveRepositoryTransactionProcessor.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/NaiveRepositoryTransactionProcessor.java index f7f2f6a649a0..6d59bd5f65cc 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/NaiveRepositoryTransactionProcessor.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/NaiveRepositoryTransactionProcessor.java @@ -27,6 +27,7 @@ public class NaiveRepositoryTransactionProcessor { IRepository myRepository; + public NaiveRepositoryTransactionProcessor(IRepository theRepository) { myRepository = theRepository; } @@ -75,26 +76,30 @@ B processTransaction(B transaction, Map now, responseOutcome.getOperationOutcome()); bundleBuilder.addEntry(responseEntryBuilder.apply(response)); - } case DELETE -> { IdDt idDt = new IdDt(e.getUrl()); String resourceType = idDt.getResourceType(); Validate.notBlank(resourceType, "Missing resource type for deletion %s", e.getUrl()); - MethodOutcome responseOutcome = myRepository.delete(myRepository.fhirContext().getResourceDefinition(resourceType).getImplementingClass(), idDt); + MethodOutcome responseOutcome = myRepository.delete( + myRepository + .fhirContext() + .getResourceDefinition(resourceType) + .getImplementingClass(), + idDt); BundleResponseEntryParts response = new BundleResponseEntryParts( - e.getFullUrl(), - null, - InMemoryFhirRepository.statusCodeToStatusLine(responseOutcome.getResponseStatusCode()), - null, - null, - now, - responseOutcome.getOperationOutcome()); + e.getFullUrl(), + null, + InMemoryFhirRepository.statusCodeToStatusLine(responseOutcome.getResponseStatusCode()), + null, + null, + now, + responseOutcome.getOperationOutcome()); bundleBuilder.addEntry(responseEntryBuilder.apply(response)); } default -> throw new NotImplementedOperationException( - "Transaction stub only supports PUT, POST or DELETE"); + "Transaction stub only supports POST, PUT, or DELETE"); } } diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/SchemeBasedFhirRepositoryLoader.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/SchemeBasedFhirRepositoryLoader.java index c46dc4398335..fa044df0b8c4 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/SchemeBasedFhirRepositoryLoader.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/SchemeBasedFhirRepositoryLoader.java @@ -1,10 +1,19 @@ package ca.uhn.fhir.repository.impl; +/** + * Simple base class for {@link IRepositoryLoader} implementations that select on the sub-scheme of the URL. + */ public abstract class SchemeBasedFhirRepositoryLoader implements IRepositoryLoader { - final String myScheme; + final String mySubScheme; - protected SchemeBasedFhirRepositoryLoader(String theScheme) { - myScheme = theScheme; + /** + * Constructor + * + * @param theSubScheme The sub-scheme to match against. For example, if the URL is "fhir-repository:ig-filesystem:...", + * then the sub-scheme is "ig-filesystem". + */ + protected SchemeBasedFhirRepositoryLoader(String theSubScheme) { + mySubScheme = theSubScheme; } public boolean canLoad(IRepositoryLoader.IRepositoryRequest theRepositoryRequest) { @@ -12,6 +21,6 @@ public boolean canLoad(IRepositoryLoader.IRepositoryRequest theRepositoryRequest return false; } - return myScheme.equals(theRepositoryRequest.getSubScheme()); + return mySubScheme.equals(theRepositoryRequest.getSubScheme()); } } diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java index 5503c2e2cd4d..3318cc015714 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java @@ -773,9 +773,9 @@ public DaoMethodOutcome delete( // Because no object actually existed, we'll // just set the id and nothing else DaoMethodOutcome daoMethodOutcome = createMethodOutcomeForResourceId( - theId.getValue(), - MESSAGE_KEY_DELETE_RESOURCE_NOT_EXISTING, - StorageResponseCodeEnum.SUCCESSFUL_DELETE_NOT_FOUND); + theId.getValue(), + MESSAGE_KEY_DELETE_RESOURCE_NOT_EXISTING, + StorageResponseCodeEnum.SUCCESSFUL_DELETE_NOT_FOUND); daoMethodOutcome.setResponseStatusCode(Constants.STATUS_HTTP_404_NOT_FOUND); return daoMethodOutcome; } diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java index 671faddce414..82be2d153003 100644 --- a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -26,9 +26,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.annotation.Nonnull; import java.util.List; import java.util.Map; +import javax.annotation.Nonnull; import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_201_CREATED; import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_204_NO_CONTENT; @@ -180,7 +180,6 @@ default void testDelete_noCreate_returnsOutcome() { assertThat(outcome.getResponseStatusCode()).isEqualTo(Constants.STATUS_HTTP_404_NOT_FOUND); } - @Test @EnabledIf("isPatchSupported") default void testPatch_changesValue() { @@ -261,8 +260,7 @@ default void testSimpleTxBundle() { { BundleResponseEntryParts entry = bundleResponseEntryParts.get(2); - assertThat(entry.fullUrl()) - .satisfiesAnyOf(Assertions::assertNull, fullUrl -> assertThat(fullUrl) + assertThat(entry.fullUrl()).satisfiesAnyOf(Assertions::assertNull, fullUrl -> assertThat(fullUrl) .contains(deletePatientId.getIdPart())); assertThat(entry.responseStatus()).startsWith("" + STATUS_HTTP_204_NO_CONTENT); } @@ -271,7 +269,7 @@ default void testSimpleTxBundle() { @EnabledIf("isSearchSupported") @Test default void testSearchAllOfType() { - // given + // given FhirContext context = getRepository().fhirContext(); var repository = getRepository(); var b = getTestDataBuilder(); @@ -283,12 +281,11 @@ default void testSearchAllOfType() { // when IBaseBundle searchResult = repository.search(bundle.getClass(), patientClass, Map.of()); - // then + // then List entries = BundleUtil.getSearchBundleEntryParts(context, searchResult); assertThat(entries).hasSize(2); } - /** Implementors of this test template must provide a RepositoryTestSupport instance */ RepositoryTestSupport getRepositoryTestSupport(); From 11b7d51b8d52f9723df5374d70dc8316532757a3 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 11 Jul 2025 23:06:43 -0400 Subject: [PATCH 20/65] Revert "Bump to core 6.5.27 (#7094)" This reverts commit c3bc9bdd27009d8ed58fecaeac1e0d4bc4e6e90e. --- .../uhn/fhir/context/MyEpisodeOfCareFHIR.java | 34 +++++++++++++++++++ .../fhir/r5/hapi/ctx/HapiWorkerContext.java | 14 ++++---- ...WorkerContextValidationSupportAdapter.java | 10 +++--- .../FhirInstanceValidatorR4Test.java | 4 +-- .../FhirInstanceValidatorR4BTest.java | 4 +-- pom.xml | 2 +- 6 files changed, 52 insertions(+), 16 deletions(-) diff --git a/hapi-fhir-structures-dstu3/src/test/java/ca/uhn/fhir/context/MyEpisodeOfCareFHIR.java b/hapi-fhir-structures-dstu3/src/test/java/ca/uhn/fhir/context/MyEpisodeOfCareFHIR.java index 4bca3a24024a..4925ba218816 100644 --- a/hapi-fhir-structures-dstu3/src/test/java/ca/uhn/fhir/context/MyEpisodeOfCareFHIR.java +++ b/hapi-fhir-structures-dstu3/src/test/java/ca/uhn/fhir/context/MyEpisodeOfCareFHIR.java @@ -476,6 +476,12 @@ public List getAccount() { throw new UnsupportedOperationException("Deprecated method"); } + @Override + @Deprecated + public List getAccountTarget() { + throw new UnsupportedOperationException("Deprecated method"); + } + @Override @Deprecated public List getDiagnosis() { @@ -494,6 +500,11 @@ public List getReferralRequest() { throw new UnsupportedOperationException("Deprecated method"); } + @Override + @Deprecated + public List getReferralRequestTarget() { + throw new UnsupportedOperationException("Deprecated method"); + } @Override @Deprecated @@ -507,6 +518,11 @@ public List getTeam() { throw new UnsupportedOperationException("Deprecated method"); } + @Override + @Deprecated + public List getTeamTarget() { + throw new UnsupportedOperationException("Deprecated method"); + } @Override @Deprecated @@ -514,6 +530,12 @@ public List getType() { throw new UnsupportedOperationException("Deprecated method"); } + @Override + @Deprecated + public Account addAccountTarget() { + throw new UnsupportedOperationException("Deprecated method"); + } + @Override @Deprecated public Base addChild(String p0) { @@ -544,6 +566,12 @@ public Base[] getProperty(int p0, String p1, boolean p2) { throw new UnsupportedOperationException("Deprecated method"); } + @Override + @Deprecated + public CareTeam addTeamTarget() { + throw new UnsupportedOperationException("Deprecated method"); + } + @Override @Deprecated public CodeableConcept addType() { @@ -820,6 +848,12 @@ public Reference getTeamFirstRep() { throw new UnsupportedOperationException("Deprecated method"); } + @Override + @Deprecated + public ReferralRequest addReferralRequestTarget() { + throw new UnsupportedOperationException("Deprecated method"); + } + @Override @Deprecated public ResourceType getResourceType() { diff --git a/hapi-fhir-structures-r5/src/main/java/org/hl7/fhir/r5/hapi/ctx/HapiWorkerContext.java b/hapi-fhir-structures-r5/src/main/java/org/hl7/fhir/r5/hapi/ctx/HapiWorkerContext.java index b2d0c6080dc4..4b6b6684e5de 100644 --- a/hapi-fhir-structures-r5/src/main/java/org/hl7/fhir/r5/hapi/ctx/HapiWorkerContext.java +++ b/hapi-fhir-structures-r5/src/main/java/org/hl7/fhir/r5/hapi/ctx/HapiWorkerContext.java @@ -22,7 +22,6 @@ import org.hl7.fhir.r5.model.Coding; import org.hl7.fhir.r5.model.ElementDefinition.ElementDefinitionBindingComponent; import org.hl7.fhir.r5.model.NamingSystem; -import org.hl7.fhir.r5.model.OperationOutcome; import org.hl7.fhir.r5.model.PackageInformation; import org.hl7.fhir.r5.model.Parameters; import org.hl7.fhir.r5.model.Resource; @@ -44,6 +43,8 @@ import org.hl7.fhir.utilities.validation.ValidationMessage.IssueSeverity; import org.hl7.fhir.utilities.validation.ValidationOptions; +import java.io.FileNotFoundException; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -534,6 +535,12 @@ public int loadFromPackage(NpmPackage pi, IContextResourceLoader loader) throws throw new UnsupportedOperationException(Msg.code(233)); } + @Override + public int loadFromPackage(NpmPackage pi, IContextResourceLoader loader, Set types) + throws FileNotFoundException, IOException, FHIRException { + throw new UnsupportedOperationException(Msg.code(2328)); + } + @Override public int loadFromPackageAndDependencies(NpmPackage pi, IContextResourceLoader loader, BasePackageCacheManager pcm) throws FHIRException { @@ -657,9 +664,4 @@ public Boolean subsumes(ValidationOptions optionsArg, Coding parent, Coding chil public boolean isServerSideSystem(String url) { return false; } - - @Override - public OperationOutcome validateTxResource(ValidationOptions options, Resource resource) { - throw new UnsupportedOperationException(Msg.code(2734)); - } } diff --git a/hapi-fhir-validation/src/main/java/org/hl7/fhir/common/hapi/validation/validator/WorkerContextValidationSupportAdapter.java b/hapi-fhir-validation/src/main/java/org/hl7/fhir/common/hapi/validation/validator/WorkerContextValidationSupportAdapter.java index d5799dda8d0b..6f1a64b89c8d 100644 --- a/hapi-fhir-validation/src/main/java/org/hl7/fhir/common/hapi/validation/validator/WorkerContextValidationSupportAdapter.java +++ b/hapi-fhir-validation/src/main/java/org/hl7/fhir/common/hapi/validation/validator/WorkerContextValidationSupportAdapter.java @@ -200,6 +200,11 @@ public int loadFromPackage(NpmPackage pi, IContextResourceLoader loader) throws throw new UnsupportedOperationException(Msg.code(652)); } + @Override + public int loadFromPackage(NpmPackage pi, IContextResourceLoader loader, Set types) throws FHIRException { + throw new UnsupportedOperationException(Msg.code(653)); + } + @Override public int loadFromPackageAndDependencies(NpmPackage pi, IContextResourceLoader loader, BasePackageCacheManager pcm) throws FHIRException { @@ -1148,9 +1153,4 @@ public static WorkerContextValidationSupportAdapter newVersionSpecificWorkerCont IValidationSupport theValidationSupport) { return new WorkerContextValidationSupportAdapter(theValidationSupport); } - - @Override - public OperationOutcome validateTxResource(ValidationOptions options, Resource resource) { - throw new UnsupportedOperationException(Msg.code(2735)); - } } diff --git a/hapi-fhir-validation/src/test/java/org/hl7/fhir/r4/validation/FhirInstanceValidatorR4Test.java b/hapi-fhir-validation/src/test/java/org/hl7/fhir/r4/validation/FhirInstanceValidatorR4Test.java index 5c6a603aa8ab..b05a3b045c66 100644 --- a/hapi-fhir-validation/src/test/java/org/hl7/fhir/r4/validation/FhirInstanceValidatorR4Test.java +++ b/hapi-fhir-validation/src/test/java/org/hl7/fhir/r4/validation/FhirInstanceValidatorR4Test.java @@ -567,8 +567,8 @@ public void testLargeBase64() { String input = ClasspathUtil.loadResource("/r4/diagnosticreport-example-gingival-mass.json"); ValidationResult output = myFhirValidator.validateWithResult(input); List messages = logResultsAndReturnAll(output); - assertThat(messages).hasSize(2); - assertEquals("Base64 encoded values SHOULD not contain any whitespace (per RFC 4648). Note that non-validating readers are encouraged to accept whitespace anyway", messages.get(1).getMessage()); + assertThat(messages).hasSize(1); + assertEquals("Base64 encoded values SHOULD not contain any whitespace (per RFC 4648). Note that non-validating readers are encouraged to accept whitespace anyway", messages.get(0).getMessage()); } @Test diff --git a/hapi-fhir-validation/src/test/java/org/hl7/fhir/r4b/validation/FhirInstanceValidatorR4BTest.java b/hapi-fhir-validation/src/test/java/org/hl7/fhir/r4b/validation/FhirInstanceValidatorR4BTest.java index 7d24e5db5d69..3c0c1e3b9967 100644 --- a/hapi-fhir-validation/src/test/java/org/hl7/fhir/r4b/validation/FhirInstanceValidatorR4BTest.java +++ b/hapi-fhir-validation/src/test/java/org/hl7/fhir/r4b/validation/FhirInstanceValidatorR4BTest.java @@ -524,8 +524,8 @@ public void testLargeBase64() throws IOException { String input = IOUtils.toString(FhirInstanceValidatorR4BTest.class.getResourceAsStream("/r4/diagnosticreport-example-gingival-mass.json"), Constants.CHARSET_UTF8); ValidationResult output = myFhirValidator.validateWithResult(input); List messages = logResultsAndReturnAll(output); - assertThat(messages).hasSize(2); - assertEquals("Base64 encoded values SHOULD not contain any whitespace (per RFC 4648). Note that non-validating readers are encouraged to accept whitespace anyway", messages.get(1).getMessage()); + assertThat(messages).hasSize(1); + assertEquals("Base64 encoded values SHOULD not contain any whitespace (per RFC 4648). Note that non-validating readers are encouraged to accept whitespace anyway", messages.get(0).getMessage()); } @Test diff --git a/pom.xml b/pom.xml index 22c53ba28a55..32937f52a19c 100644 --- a/pom.xml +++ b/pom.xml @@ -985,7 +985,7 @@ - 6.5.27 + 6.5.26 2.41.1 **/test/**/*.java -Dfile.encoding=UTF-8 -Xmx2048m From 53e402449d6896e7e172a4deba261217af209734 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 11 Jul 2025 23:34:25 -0400 Subject: [PATCH 21/65] Fix status changes --- .../java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java | 1 - .../uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java | 1 - .../fhir/jpa/repository/HapiFhirRepository.java | 15 ++++++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java index 0faa860138e8..69bdf6fdce38 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirDao.java @@ -1471,7 +1471,6 @@ public DaoMethodOutcome updateInternal( DaoMethodOutcome outcome = toMethodOutcome( theRequestDetails, savedEntity, theResource, theMatchUrl, theOperationType) .setCreated(wasDeleted); - outcome.setResponseStatusCode(Constants.STATUS_HTTP_200_OK); if (!thePerformIndexing) { IIdType id = getContext().getVersion().newIdType(); diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java index 3fed71e4a646..f1c36b0cc7a0 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java @@ -799,7 +799,6 @@ public DaoMethodOutcome delete( // used to exist, so we'll set the persistent id outcome.setPersistentId(persistentId); outcome.setEntity(entity); - outcome.setResponseStatusCode(Constants.STATUS_HTTP_410_GONE); return outcome; } diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java index 951c1aac155f..a4837e2757a1 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/repository/HapiFhirRepository.java @@ -22,6 +22,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome; import ca.uhn.fhir.model.api.IQueryParameterType; import ca.uhn.fhir.model.api.Include; import ca.uhn.fhir.model.valueset.BundleTypeEnum; @@ -115,7 +116,14 @@ public MethodOutcome update(T theResource, Map B createBundle( offset = 0; } int start = offset; - if (theBundleProvider.size() != null) { - start = Math.max(0, Math.min(offset, theBundleProvider.size())); + Integer size = theBundleProvider.size(); + if (size != null) { + start = Math.max(0, Math.min(offset, size)); } BundleTypeEnum bundleType; From 685aadb39d8d6b79806c2cb87aef4b63c026e692 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Fri, 11 Jul 2025 23:55:51 -0400 Subject: [PATCH 22/65] Changelog --- .../impl/InMemoryFhirRepositoryLoader.java | 4 +- .../repository/impl/UrlRepositoryFactory.java | 58 ++++++++++--------- .../8_4_0/7104-in-memory-repository.yaml | 7 +++ 3 files changed, 39 insertions(+), 30 deletions(-) create mode 100644 hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7104-in-memory-repository.yaml diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java index 28a60665a5c5..99df0b58b8e5 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java @@ -1,6 +1,7 @@ package ca.uhn.fhir.repository.impl; import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.repository.IRepository; import jakarta.annotation.Nonnull; import org.apache.commons.collections4.map.ReferenceMap; @@ -19,9 +20,8 @@ public InMemoryFhirRepositoryLoader() { public IRepository loadRepository(@Nonnull IRepositoryRequest theRepositoryRequest) { FhirContext context = theRepositoryRequest .getFhirContext() - // fixme hapi-code .orElseThrow( - () -> new IllegalArgumentException("The :memory: FHIR repository requires a FhirContext.")); + () -> new IllegalArgumentException(Msg.code(2736) + "The :memory: FHIR repository requires a FhirContext.")); String memoryKey = theRepositoryRequest.getDetails(); return ourRepositories.computeIfAbsent(memoryKey, k -> { diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java index bd69a7014708..23fc438e191f 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/UrlRepositoryFactory.java @@ -13,6 +13,10 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * Use ServiceLoader to load {@link IRepositoryLoader} implementations + * and provide chain-of-responsibility style matching by url to build IRepository instances. + */ public class UrlRepositoryFactory { private static final Logger ourLog = IRepository.ourLog; @@ -25,38 +29,14 @@ public static boolean isRepositoryUrl(String theBaseUrl) { && ourUrlPattern.matcher(theBaseUrl).matches(); } - protected record RepositoryRequest(String url, String subScheme, String details, FhirContext fhirContext) - implements IRepositoryLoader.IRepositoryRequest { - @Override - public String getUrl() { - return url; - } - - @Override - public String getSubScheme() { - return subScheme; - } - - @Override - public String getDetails() { - return details; - } - - @Override - public Optional getFhirContext() { - return Optional.ofNullable(fhirContext); - } - } - @Nonnull public static IRepository buildRepository(@Nonnull String theBaseUrl, @Nullable FhirContext theFhirContext) { ourLog.debug("Loading repository for url: {}", theBaseUrl); Objects.requireNonNull(theBaseUrl); if (!isRepositoryUrl(theBaseUrl)) { - // fixme hapi-code throw new IllegalArgumentException( - Msg.code(99997) + "Base URL is not a valid repository URL: " + theBaseUrl); + Msg.code(2737) + "Base URL is not a valid repository URL: " + theBaseUrl); } ServiceLoader load = ServiceLoader.load(IRepositoryLoader.class); @@ -67,13 +47,12 @@ public static IRepository buildRepository(@Nonnull String theBaseUrl, @Nullable return nextLoader.loadRepository(request); } } - // fixme hapi-code throw new IllegalArgumentException( - Msg.code(99999) + "Unable to find a repository loader for URL: " + theBaseUrl); + Msg.code(2738) + "Unable to find a repository loader for URL: " + theBaseUrl); } @Nonnull - protected static RepositoryRequest buildRequest(@Nonnull String theBaseUrl, @Nullable FhirContext theFhirContext) { + static RepositoryRequest buildRequest(@Nonnull String theBaseUrl, @Nullable FhirContext theFhirContext) { Matcher matcher = ourUrlPattern.matcher(theBaseUrl); String subScheme = null; String details = null; @@ -85,4 +64,27 @@ protected static RepositoryRequest buildRequest(@Nonnull String theBaseUrl, @Nul return new RepositoryRequest(theBaseUrl, subScheme, details, theFhirContext); } + + record RepositoryRequest(String url, String subScheme, String details, FhirContext fhirContext) + implements IRepositoryLoader.IRepositoryRequest { + @Override + public String getUrl() { + return url; + } + + @Override + public String getSubScheme() { + return subScheme; + } + + @Override + public String getDetails() { + return details; + } + + @Override + public Optional getFhirContext() { + return Optional.ofNullable(fhirContext); + } + } } diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7104-in-memory-repository.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7104-in-memory-repository.yaml new file mode 100644 index 000000000000..29f2259926f2 --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7104-in-memory-repository.yaml @@ -0,0 +1,7 @@ +--- +type: add +issue: 7104 +title: "The new Repositories builder supports creating instances of IRepository by urls. + We include a limited in-memory implementation of IRepository useful for unit-testing + that can be created with Repositories.repositoryForUrl(\"fhir-repository:memory:test-repo\", fhirContext). + New implementations can be registered with a ServiceLoader. See IRepositoryLoader and From c84829f5e8f11f416e7139c49878e938db23ca79 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Sat, 12 Jul 2025 00:07:19 -0400 Subject: [PATCH 23/65] spotless --- .../fhir/repository/impl/InMemoryFhirRepositoryLoader.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java index 99df0b58b8e5..8511984fe730 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepositoryLoader.java @@ -20,8 +20,8 @@ public InMemoryFhirRepositoryLoader() { public IRepository loadRepository(@Nonnull IRepositoryRequest theRepositoryRequest) { FhirContext context = theRepositoryRequest .getFhirContext() - .orElseThrow( - () -> new IllegalArgumentException(Msg.code(2736) + "The :memory: FHIR repository requires a FhirContext.")); + .orElseThrow(() -> new IllegalArgumentException( + Msg.code(2736) + "The :memory: FHIR repository requires a FhirContext.")); String memoryKey = theRepositoryRequest.getDetails(); return ourRepositories.computeIfAbsent(memoryKey, k -> { From 000500075763c9eb3fdacb491540cebc8fd9d357 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Sat, 12 Jul 2025 00:28:56 -0400 Subject: [PATCH 24/65] fixmes --- .../fhir/repository/impl/InMemoryFhirRepository.java | 12 +++++++++--- .../matcher/MultiVersionResourceMatcher.java | 11 ++--------- .../changelog/8_4_0/7104-in-memory-repository.yaml | 3 ++- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java index cd09bfa6ee04..5ef7c5dd9d08 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java @@ -32,14 +32,20 @@ /** * An in-memory implementation of the FHIR repository interface. - * Based on org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository. * This repository stores resources in memory * and provides basic CRUD operations, search, and transaction support. - * SOMEDAY add support for extended operations and custom search parameters. + * Limitations: + *

    + *
  • Does not support versioning of resources.
  • + *
  • Does not search beyond all-of-type - no SearchParameters are supported.
  • + *
  • Does not support extended operations.
  • + *
  • Does not support conditional update or create.
  • + *
  • Does not support PATCH operations.
  • + *
*/ public class InMemoryFhirRepository implements IRepository { + // Based on org.opencds.cqf.fhir.utility.repository.InMemoryFhirRepository. - // fixme add sketch of extended operations private String myBaseUrl; private final Map> resourceMap; private final FhirContext context; diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java index 5e345be26fd7..f692622f1d51 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/matcher/MultiVersionResourceMatcher.java @@ -3,6 +3,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.RuntimeSearchParam; import ca.uhn.fhir.fhirpath.IFhirPath; +import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.rest.param.DateRangeParam; import ca.uhn.fhir.rest.param.TokenParam; import ca.uhn.fhir.util.FhirTerser; @@ -54,15 +55,7 @@ public Map getCustomParameters() { @Override public DateRangeParam getDateRange(ICompositeType type) { - // fixme implement - // if (type instanceof Period) { - // return new DateRangeParam(((Period) type).getStart(), ((Period) type).getEnd()); - // } else if (type instanceof Timing) { - // throw new NotImplementedException("Timing resolution has not yet been implemented"); - // } else { - throw new UnsupportedOperationException("Expected element of type Period or Timing, found " - + type.getClass().getSimpleName()); - // } + throw new UnsupportedOperationException(Msg.code(2738) + "Date range extraction is not supported for type"); } @Override diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7104-in-memory-repository.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7104-in-memory-repository.yaml index 29f2259926f2..eff46c1de555 100644 --- a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7104-in-memory-repository.yaml +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/7104-in-memory-repository.yaml @@ -4,4 +4,5 @@ issue: 7104 title: "The new Repositories builder supports creating instances of IRepository by urls. We include a limited in-memory implementation of IRepository useful for unit-testing that can be created with Repositories.repositoryForUrl(\"fhir-repository:memory:test-repo\", fhirContext). - New implementations can be registered with a ServiceLoader. See IRepositoryLoader and + New implementations can be registered with a ServiceLoader. + See IRepositoryLoader and InMemoryFhirRepositoryLoader for examples." From 109f7eb70d0752865ac2d53aedd8849d4844aa4f Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Sat, 12 Jul 2025 10:03:56 -0400 Subject: [PATCH 25/65] Revert delete status changes --- .../java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java | 4 +--- .../src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java index f1c36b0cc7a0..c376ecc88814 100644 --- a/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java +++ b/hapi-fhir-jpaserver-base/src/main/java/ca/uhn/fhir/jpa/dao/BaseHapiFhirResourceDao.java @@ -773,12 +773,10 @@ public DaoMethodOutcome delete( // if not found, return an outcome anyway. // Because no object actually existed, we'll // just set the id and nothing else - DaoMethodOutcome daoMethodOutcome = createMethodOutcomeForResourceId( + return createMethodOutcomeForResourceId( theId.getValue(), MESSAGE_KEY_DELETE_RESOURCE_NOT_EXISTING, StorageResponseCodeEnum.SUCCESSFUL_DELETE_NOT_FOUND); - daoMethodOutcome.setResponseStatusCode(Constants.STATUS_HTTP_404_NOT_FOUND); - return daoMethodOutcome; } if (theId.hasVersionIdPart() && Long.parseLong(theId.getVersionIdPart()) != entity.getVersion()) { diff --git a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java index 82be2d153003..92f24d7f5ef9 100644 --- a/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java +++ b/hapi-fhir-test-utilities/src/main/java/ca/uhn/fhir/repository/IRepositoryTest.java @@ -177,7 +177,6 @@ default void testDelete_noCreate_returnsOutcome() { // then assertThat(outcome).isNotNull(); - assertThat(outcome.getResponseStatusCode()).isEqualTo(Constants.STATUS_HTTP_404_NOT_FOUND); } @Test From 4bfb9d84964b182385da5acc042e3e7113c611f3 Mon Sep 17 00:00:00 2001 From: Michael Buckley Date: Sat, 12 Jul 2025 12:07:09 -0400 Subject: [PATCH 26/65] Extract search logic --- .../impl/InMemoryFhirRepository.java | 104 ++++--------- .../fhir/repository/impl/NaiveSearching.java | 140 ++++++++++++++++++ .../java/ca/uhn/fhir/util/BundleBuilder.java | 13 ++ ...ry.yaml => 7604-in-memory-repository.yaml} | 2 +- .../fhir/jpa/dao/HapiFhirRepositoryTest.java | 1 + .../uhn/fhir/repository/IRepositoryTest.java | 27 ++++ 6 files changed, 214 insertions(+), 73 deletions(-) create mode 100644 hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/NaiveSearching.java rename hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/8_4_0/{7104-in-memory-repository.yaml => 7604-in-memory-repository.yaml} (97%) diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java index 5ef7c5dd9d08..5578dedf902a 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/repository/impl/InMemoryFhirRepository.java @@ -8,7 +8,6 @@ import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.server.exceptions.NotImplementedOperationException; import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; -import ca.uhn.fhir.util.BundleBuilder; import ca.uhn.fhir.util.BundleUtil; import ca.uhn.fhir.util.OperationOutcomeUtil; import com.google.common.annotations.VisibleForTesting; @@ -20,10 +19,10 @@ import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; -import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -37,7 +36,8 @@ * Limitations: *