From b88ccbc7b059b2dd5e8c302fe1e1b80622cd8275 Mon Sep 17 00:00:00 2001 From: Ken Stevens Date: Tue, 11 Apr 2023 20:44:08 -0400 Subject: [PATCH] R4B SubscriptionTopic support (#4724) * add tests for r4b subscriptions * begin with failing test * prepare for SubscriptionTopicLoader * backwards compatibility * subscription topic registry done * topic matching is working * all but delivery is working now * yay test passes with FIXMEs * FIXME -> WIP * switch notification to bundle * message codes * fixme * disable services for fhir versions below R4B * fix regression * fix intermittent * this change will likely break some other tests * try a safer option * fix tests * fix intermittent (I hope) * unit test * improve logic around topic subscription categorization * moar test * moar test * changed to support both r4b and r5 * moar test * cleanup for test * moar test * moar test * moar test * changelog * comment * Msg.code * fix mock * add update test * fix test cleanup * tracking link for version converter issue * review * fix test * fix test --------- Co-authored-by: Ken Stevens --- .../ca/uhn/fhir/interceptor/api/Pointcut.java | 37 + .../6_6_0/4624-subscription-topic.yaml | 5 + .../jpa/mdm/config/MdmSubscriptionLoader.java | 4 +- .../matcher/InMemoryMatchResult.java | 45 + .../matcher/InMemoryMatchResultTest.java | 59 + hapi-fhir-jpaserver-subscription/pom.xml | 2 +- .../config/SubscriptionProcessorConfig.java | 3 +- ...scriptionDeliveringRestHookSubscriber.java | 14 +- .../SubscriptionStrategyEvaluator.java | 31 +- .../MatchingQueueSubscriberLoader.java | 11 + .../SubscriptionMatchDeliverer.java | 96 ++ .../SubscriptionMatchingSubscriber.java | 73 +- .../registry/ActiveSubscriptionCache.java | 17 +- .../match/registry/SubscriptionLoader.java | 168 +-- .../match/registry/SubscriptionRegistry.java | 4 + .../SubscriptionMatcherInterceptor.java | 1 + .../SubscriptionValidatingInterceptor.java | 32 +- .../topic/ActiveSubscriptionTopicCache.java | 47 + .../jpa/topic/SubscriptionTopicConfig.java | 41 + .../jpa/topic/SubscriptionTopicLoader.java | 119 ++ .../jpa/topic/SubscriptionTopicMatcher.java | 37 + .../SubscriptionTopicMatchingSubscriber.java | 97 ++ .../SubscriptionTopicPayloadBuilder.java | 71 + .../jpa/topic/SubscriptionTopicRegistry.java | 29 + .../jpa/topic/SubscriptionTopicSupport.java | 29 + .../fhir/jpa/topic/SubscriptionTopicUtil.java | 24 + .../jpa/topic/SubscriptionTriggerMatcher.java | 87 ++ .../registry/ActiveSubscriptionCacheTest.java | 39 +- .../registry/SubscriptionLoaderTest.java | 22 +- .../module/cache/SubscriptionLoaderTest.java | 9 +- .../SubscriptionMatchingSubscriberTest.java | 20 +- ...SubscriptionValidatingInterceptorTest.java | 60 +- .../ActiveSubscriptionTopicCacheTest.java | 38 + ...ubscriptionTopicPayloadBuilderR4BTest.java | 91 ++ ...SubscriptionTopicPayloadBuilderR5Test.java | 91 ++ .../jpa/topic/SubscriptionTopicUtilTest.java | 31 + .../topic/SubscriptionTriggerMatcherTest.java | 133 ++ ...SubscriptionValidatingInterceptorTest.java | 9 +- .../BaseSubscriptionsR4BTest.java | 244 ++++ .../jpa/subscription/RestHookTestR4BTest.java | 1285 +++++++++++++++++ .../SubscriptionTopicR4BTest.java | 243 ++++ .../subscription/BaseSubscriptionsR5Test.java | 11 +- .../subscription/SubscriptionTopicR5Test.java | 169 +++ .../ca/uhn/fhir/jpa/test/BaseJpaTest.java | 20 +- .../rest/server/SimpleBundleProvider.java | 7 + .../cache/BaseResourceCacheSynchronizer.java | 204 +++ .../SubscriptionMatchingStrategy.java | 9 +- .../registry/SubscriptionCanonicalizer.java | 136 +- .../match/registry/SubscriptionConstants.java | 1 + .../model/CanonicalSubscription.java | 10 + 50 files changed, 3747 insertions(+), 318 deletions(-) create mode 100644 hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4624-subscription-topic.yaml create mode 100644 hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryMatchResultTest.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/SubscriptionMatchDeliverer.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/ActiveSubscriptionTopicCache.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicConfig.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicLoader.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicMatcher.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicMatchingSubscriber.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilder.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicRegistry.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicSupport.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicUtil.java create mode 100644 hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTriggerMatcher.java create mode 100644 hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/ActiveSubscriptionTopicCacheTest.java create mode 100644 hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilderR4BTest.java create mode 100644 hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilderR5Test.java create mode 100644 hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicUtilTest.java create mode 100644 hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTriggerMatcherTest.java create mode 100644 hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/BaseSubscriptionsR4BTest.java create mode 100644 hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/RestHookTestR4BTest.java create mode 100644 hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionTopicR4BTest.java create mode 100644 hapi-fhir-jpaserver-test-r5/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionTopicR5Test.java create mode 100644 hapi-fhir-storage/src/main/java/ca/uhn/fhir/cache/BaseResourceCacheSynchronizer.java diff --git a/hapi-fhir-base/src/main/java/ca/uhn/fhir/interceptor/api/Pointcut.java b/hapi-fhir-base/src/main/java/ca/uhn/fhir/interceptor/api/Pointcut.java index fbb3e1b0a05..9ef7a8dc177 100644 --- a/hapi-fhir-base/src/main/java/ca/uhn/fhir/interceptor/api/Pointcut.java +++ b/hapi-fhir-base/src/main/java/ca/uhn/fhir/interceptor/api/Pointcut.java @@ -1031,6 +1031,43 @@ public enum Pointcut implements IPointcut { "org.hl7.fhir.instance.model.api.IBaseResource" ), + /** + * Subscription Topic Hook: + * Invoked whenever a persisted resource (a resource that has just been stored in the + * database via a create/update/patch/etc.) is about to be checked for whether any subscription topics + * were triggered as a result of the operation. + *

+ * Hooks may accept the following parameters: + *

+ *

+ *

+ * Hooks may return void or may return a boolean. If the method returns + * void or true, processing will continue normally. If the method + * returns false, processing will be aborted. + *

+ */ + SUBSCRIPTION_TOPIC_BEFORE_PERSISTED_RESOURCE_CHECKED(boolean.class, "ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage"), + + + /** + * Subscription Topic Hook: + * Invoked whenever a persisted resource (a resource that has just been stored in the + * database via a create/update/patch/etc.) has been checked for whether any subscription topics + * were triggered as a result of the operation. + *

+ * Hooks may accept the following parameters: + *

+ *

+ *

+ * Hooks should return void. + *

+ */ + SUBSCRIPTION_TOPIC_AFTER_PERSISTED_RESOURCE_CHECKED(void.class, "ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage"), + /** * Storage Hook: diff --git a/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4624-subscription-topic.yaml b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4624-subscription-topic.yaml new file mode 100644 index 00000000000..6c9b0c5301a --- /dev/null +++ b/hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/6_6_0/4624-subscription-topic.yaml @@ -0,0 +1,5 @@ +--- +type: add +issue: 4724 +title: "Preliminary support for R5 and R4B SubscriptionTopic matching has been added. This is not yet complete, but the +simplest use cases now work. Comments in the code with prefix 'WIP STR5' indicate areas that need to be extended." diff --git a/hapi-fhir-jpaserver-mdm/src/main/java/ca/uhn/fhir/jpa/mdm/config/MdmSubscriptionLoader.java b/hapi-fhir-jpaserver-mdm/src/main/java/ca/uhn/fhir/jpa/mdm/config/MdmSubscriptionLoader.java index 1315965e257..4079b0192d2 100644 --- a/hapi-fhir-jpaserver-mdm/src/main/java/ca/uhn/fhir/jpa/mdm/config/MdmSubscriptionLoader.java +++ b/hapi-fhir-jpaserver-mdm/src/main/java/ca/uhn/fhir/jpa/mdm/config/MdmSubscriptionLoader.java @@ -24,13 +24,13 @@ 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.dao.IFhirResourceDao; -import ca.uhn.fhir.rest.api.server.SystemRequestDetails; import ca.uhn.fhir.jpa.subscription.channel.api.ChannelProducerSettings; import ca.uhn.fhir.jpa.subscription.channel.subscription.IChannelNamer; import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionLoader; import ca.uhn.fhir.mdm.api.IMdmSettings; import ca.uhn.fhir.mdm.api.MdmConstants; import ca.uhn.fhir.mdm.log.Logs; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException; import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; import ca.uhn.fhir.util.HapiExtensions; @@ -88,7 +88,7 @@ public class MdmSubscriptionLoader { } //After loading all the subscriptions, sync the subscriptions to the registry. if (subscriptions != null && subscriptions.size() > 0) { - mySubscriptionLoader.syncSubscriptions(); + mySubscriptionLoader.syncDatabaseToCache(); } } diff --git a/hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryMatchResult.java b/hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryMatchResult.java index b972503da12..f36a2ee7bda 100644 --- a/hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryMatchResult.java +++ b/hapi-fhir-jpaserver-searchparam/src/main/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryMatchResult.java @@ -19,6 +19,8 @@ */ package ca.uhn.fhir.jpa.searchparam.matcher; +import java.util.List; + public class InMemoryMatchResult { public static final String PARSE_FAIL = "Failed to translate parse query string"; public static final String STANDARD_PARAMETER = "Standard parameters not supported"; @@ -76,6 +78,10 @@ public class InMemoryMatchResult { return new InMemoryMatchResult(theUnsupportedParameter, theUnsupportedReason); } + public static InMemoryMatchResult noMatch() { + return new InMemoryMatchResult(false); + } + public boolean supported() { return mySupported; } @@ -98,4 +104,43 @@ public class InMemoryMatchResult { public void setInMemory(boolean theInMemory) { myInMemory = theInMemory; } + + public static InMemoryMatchResult and(InMemoryMatchResult theLeft, InMemoryMatchResult theRight) { + if (theLeft == null) { + return theRight; + } + if (theRight == null) { + return theLeft; + } + if (theLeft.supported() && theRight.supported()) { + return InMemoryMatchResult.fromBoolean(theLeft.matched() && theRight.matched()); + } + if (!theLeft.supported() && !theRight.supported()) { + return InMemoryMatchResult.unsupportedFromReason(List.of(theLeft.getUnsupportedReason(), theRight.getUnsupportedReason()).toString()); + } + if (!theLeft.supported()) { + return theLeft; + } + return theRight; + } + + public static InMemoryMatchResult or(InMemoryMatchResult theLeft, InMemoryMatchResult theRight) { + if (theLeft == null) { + return theRight; + } + if (theRight == null) { + return theLeft; + } + if (theLeft.matched() || theRight.matched()) { + return InMemoryMatchResult.successfulMatch(); + } + if (!theLeft.supported() && !theRight.supported()) { + return InMemoryMatchResult.unsupportedFromReason(List.of(theLeft.getUnsupportedReason(), theRight.getUnsupportedReason()).toString()); + } + if (!theLeft.supported()) { + return theLeft; + } + return theRight; + } + } diff --git a/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryMatchResultTest.java b/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryMatchResultTest.java new file mode 100644 index 00000000000..4a448668190 --- /dev/null +++ b/hapi-fhir-jpaserver-searchparam/src/test/java/ca/uhn/fhir/jpa/searchparam/matcher/InMemoryMatchResultTest.java @@ -0,0 +1,59 @@ +package ca.uhn.fhir.jpa.searchparam.matcher; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InMemoryMatchResultTest { + InMemoryMatchResult success = InMemoryMatchResult.successfulMatch(); + InMemoryMatchResult noMatch = InMemoryMatchResult.noMatch(); + InMemoryMatchResult unsupported1 = InMemoryMatchResult.unsupportedFromParameterAndReason("param1", "reason1"); + InMemoryMatchResult unsupported2 = InMemoryMatchResult.unsupportedFromParameterAndReason("param2", "reason2"); + + @Test + public void testMergeAnd() { + assertMatch(InMemoryMatchResult.and(success, success)); + assertNoMatch(InMemoryMatchResult.and(success, noMatch)); + assertNoMatchWithReason(InMemoryMatchResult.and(success, unsupported1), unsupported1.getUnsupportedReason()); + + assertNoMatch(InMemoryMatchResult.and(noMatch, success)); + assertNoMatch(InMemoryMatchResult.and(noMatch, noMatch)); + assertNoMatchWithReason(InMemoryMatchResult.and(noMatch, unsupported1), unsupported1.getUnsupportedReason()); + + assertNoMatchWithReason(InMemoryMatchResult.and(unsupported1, success), unsupported1.getUnsupportedReason()); + assertNoMatchWithReason(InMemoryMatchResult.and(unsupported1, noMatch), unsupported1.getUnsupportedReason()); + assertNoMatchWithReason(InMemoryMatchResult.and(unsupported1, unsupported2), List.of(unsupported1.getUnsupportedReason(), unsupported2.getUnsupportedReason()).toString()); + } + + @Test + public void testMergeOr() { + assertMatch(InMemoryMatchResult.or(success, success)); + assertMatch(InMemoryMatchResult.or(success, noMatch)); + assertMatch(InMemoryMatchResult.or(success, unsupported1)); + + assertMatch(InMemoryMatchResult.or(noMatch, success)); + assertNoMatch(InMemoryMatchResult.or(noMatch, noMatch)); + assertNoMatchWithReason(InMemoryMatchResult.or(noMatch, unsupported1), unsupported1.getUnsupportedReason()); + + assertMatch(InMemoryMatchResult.or(unsupported1, success)); + assertNoMatchWithReason(InMemoryMatchResult.or(unsupported1, noMatch), unsupported1.getUnsupportedReason()); + assertNoMatchWithReason(InMemoryMatchResult.or(unsupported1, unsupported2), List.of(unsupported1.getUnsupportedReason(), unsupported2.getUnsupportedReason()).toString()); + } + + private void assertNoMatchWithReason(InMemoryMatchResult theMerged, String theExpectedUnsupportedReason) { + assertNoMatch(theMerged); + assertEquals(theExpectedUnsupportedReason, theMerged.getUnsupportedReason()); + } + + private void assertMatch(InMemoryMatchResult theMerged) { + assertTrue(theMerged.matched()); + } + private void assertNoMatch(InMemoryMatchResult theMerged) { + assertFalse(theMerged.matched()); + } +} + diff --git a/hapi-fhir-jpaserver-subscription/pom.xml b/hapi-fhir-jpaserver-subscription/pom.xml index 75251619e01..a2a7f41e0b6 100644 --- a/hapi-fhir-jpaserver-subscription/pom.xml +++ b/hapi-fhir-jpaserver-subscription/pom.xml @@ -128,7 +128,7 @@ org.springframework spring-tx - + diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/config/SubscriptionProcessorConfig.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/config/SubscriptionProcessorConfig.java index fd2f614d133..3743f5197d7 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/config/SubscriptionProcessorConfig.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/config/SubscriptionProcessorConfig.java @@ -38,6 +38,7 @@ import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionRegiste import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionLoader; import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionRegistry; import ca.uhn.fhir.jpa.subscription.model.config.SubscriptionModelConfig; +import ca.uhn.fhir.jpa.topic.SubscriptionTopicConfig; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Primary; @@ -47,7 +48,7 @@ import org.springframework.context.annotation.Scope; * This Spring config should be imported by a system that pulls messages off of the * matching queue for processing, and handles delivery */ -@Import(SubscriptionModelConfig.class) +@Import({SubscriptionModelConfig.class, SubscriptionTopicConfig.class}) public class SubscriptionProcessorConfig { @Bean diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/deliver/resthook/SubscriptionDeliveringRestHookSubscriber.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/deliver/resthook/SubscriptionDeliveringRestHookSubscriber.java index 3d0c02990d4..c345e2f6ef1 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/deliver/resthook/SubscriptionDeliveringRestHookSubscriber.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/deliver/resthook/SubscriptionDeliveringRestHookSubscriber.java @@ -26,13 +26,12 @@ import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; -import ca.uhn.fhir.rest.api.server.SystemRequestDetails; -import ca.uhn.fhir.jpa.searchparam.MatchUrlService; import ca.uhn.fhir.jpa.subscription.match.deliver.BaseSubscriptionDeliverySubscriber; import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; import ca.uhn.fhir.jpa.subscription.model.ResourceDeliveryMessage; import ca.uhn.fhir.rest.api.EncodingEnum; import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; import ca.uhn.fhir.rest.client.api.Header; import ca.uhn.fhir.rest.client.api.IGenericClient; import ca.uhn.fhir.rest.client.api.IHttpClient; @@ -70,9 +69,6 @@ public class SubscriptionDeliveringRestHookSubscriber extends BaseSubscriptionDe @Autowired private DaoRegistry myDaoRegistry; - @Autowired - private MatchUrlService myMatchUrlService; - /** * Constructor */ @@ -90,7 +86,9 @@ public class SubscriptionDeliveringRestHookSubscriber extends BaseSubscriptionDe protected void doDelivery(ResourceDeliveryMessage theMsg, CanonicalSubscription theSubscription, EncodingEnum thePayloadType, IGenericClient theClient, IBaseResource thePayloadResource) { IClientExecutable operation; - if (isNotBlank(theSubscription.getPayloadSearchCriteria())) { + if (theSubscription.isTopicSubscription()) { + operation = createDeliveryRequestTopic((IBaseBundle) theMsg.getPayload(myFhirContext), theClient, thePayloadResource); + } else if (isNotBlank(theSubscription.getPayloadSearchCriteria())) { operation = createDeliveryRequestTransaction(theSubscription, theClient, thePayloadResource); } else if (thePayloadType != null) { operation = createDeliveryRequestNormal(theMsg, theClient, thePayloadResource); @@ -143,6 +141,10 @@ public class SubscriptionDeliveringRestHookSubscriber extends BaseSubscriptionDe return theClient.transaction().withBundle(bundle); } + private IClientExecutable createDeliveryRequestTopic(IBaseBundle theBundle, IGenericClient theClient, IBaseResource thePayloadResource) { + return theClient.transaction().withBundle(theBundle); + } + public IBaseResource getResource(IIdType payloadId, RequestPartitionId thePartitionId, boolean theDeletedOK) throws ResourceGoneException { RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(payloadId.getResourceType()); SystemRequestDetails systemRequestDetails = new SystemRequestDetails().setRequestPartitionId(thePartitionId); diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/matching/SubscriptionStrategyEvaluator.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/matching/SubscriptionStrategyEvaluator.java index a9ac0b618b4..c992dd5a2d5 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/matching/SubscriptionStrategyEvaluator.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/matching/SubscriptionStrategyEvaluator.java @@ -22,6 +22,7 @@ package ca.uhn.fhir.jpa.subscription.match.matcher.matching; import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult; import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryResourceMatcher; import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionCriteriaParser; +import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; import org.springframework.beans.factory.annotation.Autowired; public class SubscriptionStrategyEvaluator { @@ -36,18 +37,28 @@ public class SubscriptionStrategyEvaluator { super(); } - public SubscriptionMatchingStrategy determineStrategy(String theCriteria) { - SubscriptionCriteriaParser.SubscriptionCriteria criteria = SubscriptionCriteriaParser.parse(theCriteria); - if (criteria != null) { - if (criteria.getCriteria() != null) { - InMemoryMatchResult result = myInMemoryResourceMatcher.canBeEvaluatedInMemory(theCriteria); - if (result.supported()) { - return SubscriptionMatchingStrategy.IN_MEMORY; - } - } else { + public SubscriptionMatchingStrategy determineStrategy(CanonicalSubscription theSubscription) { + if (theSubscription.isTopicSubscription()) { + return SubscriptionMatchingStrategy.TOPIC; + } + String criteriaString = theSubscription.getCriteriaString(); + return determineStrategy(criteriaString); + } + + public SubscriptionMatchingStrategy determineStrategy(String criteriaString) { + SubscriptionCriteriaParser.SubscriptionCriteria criteria = SubscriptionCriteriaParser.parse(criteriaString); + if (criteria == null) { + return SubscriptionMatchingStrategy.DATABASE; + } + if (criteria.getCriteria() == null) { + return SubscriptionMatchingStrategy.IN_MEMORY; + } else { + InMemoryMatchResult result = myInMemoryResourceMatcher.canBeEvaluatedInMemory(criteriaString); + if (result.supported()) { return SubscriptionMatchingStrategy.IN_MEMORY; + } else { + return SubscriptionMatchingStrategy.DATABASE; } } - return SubscriptionMatchingStrategy.DATABASE; } } diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/MatchingQueueSubscriberLoader.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/MatchingQueueSubscriberLoader.java index f05cd6a9fbe..90e91baa7af 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/MatchingQueueSubscriberLoader.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/MatchingQueueSubscriberLoader.java @@ -20,10 +20,13 @@ package ca.uhn.fhir.jpa.subscription.match.matcher.subscriber; import ca.uhn.fhir.IHapiBootOrder; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; import ca.uhn.fhir.jpa.model.entity.StorageSettings; import ca.uhn.fhir.jpa.subscription.channel.api.ChannelConsumerSettings; import ca.uhn.fhir.jpa.subscription.channel.api.IChannelReceiver; import ca.uhn.fhir.jpa.subscription.channel.subscription.SubscriptionChannelFactory; +import ca.uhn.fhir.jpa.topic.SubscriptionTopicMatchingSubscriber; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -39,8 +42,12 @@ public class MatchingQueueSubscriberLoader { protected IChannelReceiver myMatchingChannel; private static final Logger ourLog = LoggerFactory.getLogger(MatchingQueueSubscriberLoader.class); @Autowired + FhirContext myFhirContext; + @Autowired private SubscriptionMatchingSubscriber mySubscriptionMatchingSubscriber; @Autowired + private SubscriptionTopicMatchingSubscriber mySubscriptionTopicMatchingSubscriber; + @Autowired private SubscriptionChannelFactory mySubscriptionChannelFactory; @Autowired private SubscriptionRegisteringSubscriber mySubscriptionRegisteringSubscriber; @@ -60,6 +67,10 @@ public class MatchingQueueSubscriberLoader { myMatchingChannel.subscribe(mySubscriptionActivatingSubscriber); myMatchingChannel.subscribe(mySubscriptionRegisteringSubscriber); ourLog.info("Subscription Matching Subscriber subscribed to Matching Channel {} with name {}", myMatchingChannel.getClass().getName(), SUBSCRIPTION_MATCHING_CHANNEL_NAME); + if (myFhirContext.getVersion().getVersion().isEqualOrNewerThan(FhirVersionEnum.R4B)) { + ourLog.info("Starting SubscriptionTopic Matching Subscriber"); + myMatchingChannel.subscribe(mySubscriptionTopicMatchingSubscriber); + } } } diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/SubscriptionMatchDeliverer.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/SubscriptionMatchDeliverer.java new file mode 100644 index 00000000000..5c6fd25b6e3 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/SubscriptionMatchDeliverer.java @@ -0,0 +1,96 @@ +package ca.uhn.fhir.jpa.subscription.match.matcher.subscriber; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.interceptor.api.HookParams; +import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult; +import ca.uhn.fhir.jpa.subscription.channel.subscription.SubscriptionChannelRegistry; +import ca.uhn.fhir.jpa.subscription.match.registry.ActiveSubscription; +import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; +import ca.uhn.fhir.jpa.subscription.model.ResourceDeliveryJsonMessage; +import ca.uhn.fhir.jpa.subscription.model.ResourceDeliveryMessage; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import ca.uhn.fhir.rest.api.EncodingEnum; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.messaging.MessageChannel; + +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; + +public class SubscriptionMatchDeliverer { + private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionMatchDeliverer.class); + private final FhirContext myFhirContext; + private final IInterceptorBroadcaster myInterceptorBroadcaster; + private final SubscriptionChannelRegistry mySubscriptionChannelRegistry; + + public SubscriptionMatchDeliverer(FhirContext theFhirContext, IInterceptorBroadcaster theInterceptorBroadcaster, SubscriptionChannelRegistry theSubscriptionChannelRegistry) { + myFhirContext = theFhirContext; + myInterceptorBroadcaster = theInterceptorBroadcaster; + mySubscriptionChannelRegistry = theSubscriptionChannelRegistry; + } + + public boolean deliverPayload(IBaseResource thePayload, ResourceModifiedMessage theMsg, ActiveSubscription theActiveSubscription, InMemoryMatchResult matchResult) { + EncodingEnum encoding = null; + + CanonicalSubscription subscription = theActiveSubscription.getSubscription(); + String subscriptionId = theActiveSubscription.getId();; + + if (subscription != null && subscription.getPayloadString() != null && !subscription.getPayloadString().isEmpty()) { + encoding = EncodingEnum.forContentType(subscription.getPayloadString()); + } + encoding = defaultIfNull(encoding, EncodingEnum.JSON); + + ResourceDeliveryMessage deliveryMsg = new ResourceDeliveryMessage(); + deliveryMsg.setPartitionId(theMsg.getPartitionId()); + + if (thePayload != null) { + deliveryMsg.setPayload(myFhirContext, thePayload, encoding); + } else { + deliveryMsg.setPayloadId(theMsg.getPayloadId(myFhirContext)); + } + deliveryMsg.setSubscription(subscription); + deliveryMsg.setOperationType(theMsg.getOperationType()); + deliveryMsg.setTransactionId(theMsg.getTransactionId()); + deliveryMsg.copyAdditionalPropertiesFrom(theMsg); + + // Interceptor call: SUBSCRIPTION_RESOURCE_MATCHED + HookParams params = new HookParams() + .add(CanonicalSubscription.class, theActiveSubscription.getSubscription()) + .add(ResourceDeliveryMessage.class, deliveryMsg) + .add(InMemoryMatchResult.class, matchResult); + if (!myInterceptorBroadcaster.callHooks(Pointcut.SUBSCRIPTION_RESOURCE_MATCHED, params)) { + ourLog.info("Interceptor has decided to abort processing of subscription {}", subscriptionId); + return false; + } + + return sendToDeliveryChannel(theActiveSubscription, deliveryMsg); + } + + private boolean sendToDeliveryChannel(ActiveSubscription nextActiveSubscription, ResourceDeliveryMessage theDeliveryMsg) { + boolean retVal = false; + ResourceDeliveryJsonMessage wrappedMsg = new ResourceDeliveryJsonMessage(theDeliveryMsg); + MessageChannel deliveryChannel = mySubscriptionChannelRegistry.getDeliverySenderChannel(nextActiveSubscription.getChannelName()); + if (deliveryChannel != null) { + retVal = true; + trySendToDeliveryChannel(wrappedMsg, deliveryChannel); + } else { + ourLog.warn("Do not have delivery channel for subscription {}", nextActiveSubscription.getId()); + } + return retVal; + } + + private void trySendToDeliveryChannel(ResourceDeliveryJsonMessage theWrappedMsg, MessageChannel theDeliveryChannel) { + try { + boolean success = theDeliveryChannel.send(theWrappedMsg); + if (!success) { + ourLog.warn("Failed to send message to Delivery Channel."); + } + } catch (RuntimeException e) { + ourLog.error("Failed to send message to Delivery Channel", e); + throw new RuntimeException(Msg.code(7) + "Failed to send message to Delivery Channel", e); + } + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/SubscriptionMatchingSubscriber.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/SubscriptionMatchingSubscriber.java index 974c68c940e..1691c88d863 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/SubscriptionMatchingSubscriber.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/subscriber/SubscriptionMatchingSubscriber.java @@ -20,28 +20,22 @@ package ca.uhn.fhir.jpa.subscription.match.matcher.subscriber; import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.interceptor.api.HookParams; import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult; -import ca.uhn.fhir.jpa.subscription.channel.subscription.SubscriptionChannelRegistry; import ca.uhn.fhir.jpa.subscription.match.matcher.matching.ISubscriptionMatcher; import ca.uhn.fhir.jpa.subscription.match.registry.ActiveSubscription; import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionRegistry; import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; -import ca.uhn.fhir.jpa.subscription.model.ResourceDeliveryJsonMessage; -import ca.uhn.fhir.jpa.subscription.model.ResourceDeliveryMessage; import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedJsonMessage; import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; -import ca.uhn.fhir.rest.api.EncodingEnum; 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 org.springframework.beans.factory.annotation.Autowired; import org.springframework.messaging.Message; -import org.springframework.messaging.MessageChannel; import org.springframework.messaging.MessageHandler; import org.springframework.messaging.MessagingException; @@ -49,7 +43,6 @@ import javax.annotation.Nonnull; import java.util.Collection; import static ca.uhn.fhir.rest.server.messaging.BaseResourceMessage.OperationTypeEnum.DELETE; -import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.apache.commons.lang3.StringUtils.isNotBlank; public class SubscriptionMatchingSubscriber implements MessageHandler { @@ -65,7 +58,7 @@ public class SubscriptionMatchingSubscriber implements MessageHandler { @Autowired private IInterceptorBroadcaster myInterceptorBroadcaster; @Autowired - private SubscriptionChannelRegistry mySubscriptionChannelRegistry; + private SubscriptionMatchDeliverer mySubscriptionMatchDeliverer; /** * Constructor @@ -147,7 +140,7 @@ public class SubscriptionMatchingSubscriber implements MessageHandler { !theMsg.getPartitionId().hasPartitionId(subscription.getRequestPartitionId())) { return false; } - String nextSubscriptionId = getId(theActiveSubscription); + String nextSubscriptionId = theActiveSubscription.getId(); if (isNotBlank(theMsg.getSubscriptionId())) { if (!theMsg.getSubscriptionId().equals(nextSubscriptionId)) { @@ -191,71 +184,13 @@ public class SubscriptionMatchingSubscriber implements MessageHandler { } IBaseResource payload = theMsg.getNewPayload(myFhirContext); - - EncodingEnum encoding = null; - if (subscription != null && subscription.getPayloadString() != null && !subscription.getPayloadString().isEmpty()) { - encoding = EncodingEnum.forContentType(subscription.getPayloadString()); - } - encoding = defaultIfNull(encoding, EncodingEnum.JSON); - - ResourceDeliveryMessage deliveryMsg = new ResourceDeliveryMessage(); - deliveryMsg.setPartitionId(theMsg.getPartitionId()); - - if (payload != null) { - deliveryMsg.setPayload(myFhirContext, payload, encoding); - } else { - deliveryMsg.setPayloadId(theMsg.getPayloadId(myFhirContext)); - } - deliveryMsg.setSubscription(subscription); - deliveryMsg.setOperationType(theMsg.getOperationType()); - deliveryMsg.setTransactionId(theMsg.getTransactionId()); - deliveryMsg.copyAdditionalPropertiesFrom(theMsg); - - // Interceptor call: SUBSCRIPTION_RESOURCE_MATCHED - HookParams params = new HookParams() - .add(CanonicalSubscription.class, theActiveSubscription.getSubscription()) - .add(ResourceDeliveryMessage.class, deliveryMsg) - .add(InMemoryMatchResult.class, matchResult); - if (!myInterceptorBroadcaster.callHooks(Pointcut.SUBSCRIPTION_RESOURCE_MATCHED, params)) { - ourLog.info("Interceptor has decided to abort processing of subscription {}", nextSubscriptionId); - return false; - } - - return sendToDeliveryChannel(theActiveSubscription, deliveryMsg); + return mySubscriptionMatchDeliverer.deliverPayload(payload, theMsg, theActiveSubscription, matchResult); } - private boolean sendToDeliveryChannel(ActiveSubscription nextActiveSubscription, ResourceDeliveryMessage theDeliveryMsg) { - boolean retVal = false; - ResourceDeliveryJsonMessage wrappedMsg = new ResourceDeliveryJsonMessage(theDeliveryMsg); - MessageChannel deliveryChannel = mySubscriptionChannelRegistry.getDeliverySenderChannel(nextActiveSubscription.getChannelName()); - if (deliveryChannel != null) { - retVal = true; - trySendToDeliveryChannel(wrappedMsg, deliveryChannel); - } else { - ourLog.warn("Do not have delivery channel for subscription {}", nextActiveSubscription.getId()); - } - return retVal; - } - - private void trySendToDeliveryChannel(ResourceDeliveryJsonMessage theWrappedMsg, MessageChannel theDeliveryChannel) { - try { - boolean success = theDeliveryChannel.send(theWrappedMsg); - if (!success) { - ourLog.warn("Failed to send message to Delivery Channel."); - } - } catch (RuntimeException e) { - ourLog.error("Failed to send message to Delivery Channel", e); - throw new RuntimeException(Msg.code(7) + "Failed to send message to Delivery Channel", e); - } - } - - private String getId(ActiveSubscription theActiveSubscription) { - return theActiveSubscription.getId(); - } private boolean resourceTypeIsAppropriateForSubscription(ActiveSubscription theActiveSubscription, IIdType theResourceId) { SubscriptionCriteriaParser.SubscriptionCriteria criteria = theActiveSubscription.getCriteria(); - String subscriptionId = getId(theActiveSubscription); + String subscriptionId = theActiveSubscription.getId(); String resourceType = theResourceId.getResourceType(); // see if the criteria matches the created object diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/ActiveSubscriptionCache.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/ActiveSubscriptionCache.java index 3de52a050bd..23eea8bd6a1 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/ActiveSubscriptionCache.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/ActiveSubscriptionCache.java @@ -23,8 +23,15 @@ import org.apache.commons.lang3.Validate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import static org.apache.commons.lang3.StringUtils.isBlank; class ActiveSubscriptionCache { private static final Logger ourLog = LoggerFactory.getLogger(ActiveSubscriptionCache.class); @@ -77,4 +84,12 @@ class ActiveSubscriptionCache { } return retval; } + + public List getTopicSubscriptionsForUrl(String theUrl) { + assert !isBlank(theUrl); + return getAll().stream() + .filter(as -> as.getSubscription().isTopicSubscription()) + .filter(as -> theUrl.equals(as.getSubscription().getCriteriaString())) + .collect(Collectors.toList()); + } } diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionLoader.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionLoader.java index 2aaae54e355..8e4e699d0a2 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionLoader.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionLoader.java @@ -19,173 +19,50 @@ */ package ca.uhn.fhir.jpa.subscription.match.registry; -import ca.uhn.fhir.jpa.api.dao.DaoRegistry; -import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; -import ca.uhn.fhir.jpa.cache.IResourceChangeEvent; -import ca.uhn.fhir.jpa.cache.IResourceChangeListener; -import ca.uhn.fhir.jpa.cache.IResourceChangeListenerCache; -import ca.uhn.fhir.jpa.cache.IResourceChangeListenerRegistry; -import ca.uhn.fhir.jpa.model.sched.ISchedulerService; -import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.cache.BaseResourceCacheSynchronizer; import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; -import ca.uhn.fhir.jpa.searchparam.retry.Retrier; import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionActivatingSubscriber; -import ca.uhn.fhir.rest.api.server.IBundleProvider; import ca.uhn.fhir.rest.param.TokenOrListParam; import ca.uhn.fhir.rest.param.TokenParam; -import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; -import com.google.common.annotations.VisibleForTesting; import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.time.DateUtils; import org.hl7.fhir.instance.model.api.IBaseResource; -import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.r4.model.Subscription; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.event.ContextClosedEvent; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; import javax.annotation.Nonnull; -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; -import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.concurrent.Semaphore; -import java.util.stream.Collectors; -public class SubscriptionLoader implements IResourceChangeListener { +public class SubscriptionLoader extends BaseResourceCacheSynchronizer { private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionLoader.class); - private static final int MAX_RETRIES = 60; // 60 * 5 seconds = 5 minutes - private static final long REFRESH_INTERVAL = DateUtils.MILLIS_PER_MINUTE; - private final Object mySyncSubscriptionsLock = new Object(); @Autowired private SubscriptionRegistry mySubscriptionRegistry; - @Autowired - DaoRegistry myDaoRegistry; - private Semaphore mySyncSubscriptionsSemaphore = new Semaphore(1); + @Autowired private SubscriptionActivatingSubscriber mySubscriptionActivatingInterceptor; - @Autowired - private ISearchParamRegistry mySearchParamRegistry; - @Autowired - private IResourceChangeListenerRegistry myResourceChangeListenerRegistry; + @Autowired private SubscriptionCanonicalizer mySubscriptionCanonicalizer; - private SearchParameterMap mySearchParameterMap; - private SystemRequestDetails mySystemRequestDetails; - private boolean myStopping; - /** * Constructor */ public SubscriptionLoader() { - super(); + super("Subscription"); } - @PostConstruct - public void registerListener() { - mySearchParameterMap = getSearchParameterMap(); - mySystemRequestDetails = SystemRequestDetails.forAllPartitions(); - - IResourceChangeListenerCache subscriptionCache = myResourceChangeListenerRegistry.registerResourceResourceChangeListener("Subscription", mySearchParameterMap, this, REFRESH_INTERVAL); - subscriptionCache.forceRefresh(); - } - - @PreDestroy - public void unregisterListener() { - myResourceChangeListenerRegistry.unregisterResourceResourceChangeListener(this); - } - - private boolean subscriptionsDaoExists() { - return myDaoRegistry != null && myDaoRegistry.isResourceTypeSupported("Subscription"); - } - - /** - * Read the existing subscriptions from the database - */ - public void syncSubscriptions() { - if (!subscriptionsDaoExists()) { - return; - } - if (!mySyncSubscriptionsSemaphore.tryAcquire()) { - return; - } - try { - doSyncSubscriptionsWithRetry(); - } finally { - mySyncSubscriptionsSemaphore.release(); - } - } - - @VisibleForTesting - public void acquireSemaphoreForUnitTest() throws InterruptedException { - mySyncSubscriptionsSemaphore.acquire(); - } - - @VisibleForTesting public int doSyncSubscriptionsForUnitTest() { - // Two passes for delete flag to take effect - int first = doSyncSubscriptionsWithRetry(); - int second = doSyncSubscriptionsWithRetry(); - return first + second; - } - - synchronized int doSyncSubscriptionsWithRetry() { - // retry runs MAX_RETRIES times - // and if errors result every time, it will fail - Retrier syncSubscriptionRetrier = new Retrier<>(this::doSyncSubscriptions, MAX_RETRIES); - return syncSubscriptionRetrier.runWithRetry(); - } - - private int doSyncSubscriptions() { - if (isStopping()) { - return 0; - } - - synchronized (mySyncSubscriptionsLock) { - ourLog.debug("Starting sync subscriptions"); - - IBundleProvider subscriptionBundleList = getSubscriptionDao().search(mySearchParameterMap, mySystemRequestDetails); - - Integer subscriptionCount = subscriptionBundleList.size(); - assert subscriptionCount != null; - if (subscriptionCount >= SubscriptionConstants.MAX_SUBSCRIPTION_RESULTS) { - ourLog.error("Currently over " + SubscriptionConstants.MAX_SUBSCRIPTION_RESULTS + " subscriptions. Some subscriptions have not been loaded."); - } - - List resourceList = subscriptionBundleList.getResources(0, subscriptionCount); - - return updateSubscriptionRegistry(resourceList); - } - } - - @EventListener(ContextRefreshedEvent.class) - public void start() { - myStopping = false; - } - - @EventListener(ContextClosedEvent.class) - public void shutdown() { - myStopping = true; - } - - private boolean isStopping() { - return myStopping; - } - - private IFhirResourceDao getSubscriptionDao() { - return myDaoRegistry.getSubscriptionDao(); + return super.doSyncResourcessForUnitTest(); } + @Override @Nonnull - private SearchParameterMap getSearchParameterMap() { + protected SearchParameterMap getSearchParameterMap() { SearchParameterMap map = new SearchParameterMap(); if (mySearchParamRegistry.getActiveSearchParam("Subscription", "status") != null) { @@ -197,6 +74,16 @@ public class SubscriptionLoader implements IResourceChangeListener { return map; } + @Override + protected void handleInit(List resourceList) { + updateSubscriptionRegistry(resourceList); + } + + @Override + protected int syncResourcesIntoCache(List resourceList) { + return updateSubscriptionRegistry(resourceList); + } + private int updateSubscriptionRegistry(List theResourceList) { Set allIds = new HashSet<>(); int activatedCount = 0; @@ -271,23 +158,8 @@ public class SubscriptionLoader implements IResourceChangeListener { ); } - @Override - public void handleInit(Collection theResourceIds) { - if (!subscriptionsDaoExists()) { - ourLog.warn("Subsriptions are enabled on this server, but there is no Subscription DAO configured."); - return; - } - IFhirResourceDao subscriptionDao = getSubscriptionDao(); - SystemRequestDetails systemRequestDetails = SystemRequestDetails.forAllPartitions(); - List resourceList = theResourceIds.stream().map(n -> subscriptionDao.read(n, systemRequestDetails)).collect(Collectors.toList()); - updateSubscriptionRegistry(resourceList); - } - - @Override - public void handleChange(IResourceChangeEvent theResourceChangeEvent) { - // For now ignore the contents of theResourceChangeEvent. In the future, consider updating the registry based on - // known subscriptions that have been created, updated & deleted - syncSubscriptions(); + public void syncSubscriptions() { + super.syncDatabaseToCache(); } } diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionRegistry.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionRegistry.java index 1be9a491213..dd52043ac12 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionRegistry.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionRegistry.java @@ -74,6 +74,10 @@ public class SubscriptionRegistry { return myActiveSubscriptionCache.getAll(); } + public synchronized List getTopicSubscriptionsByUrl(String theUrl) { + return myActiveSubscriptionCache.getTopicSubscriptionsForUrl(theUrl); + } + private Optional hasSubscription(IIdType theId) { Validate.notNull(theId); Validate.notBlank(theId.getIdPart()); diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionMatcherInterceptor.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionMatcherInterceptor.java index 3406981c04e..b8179282589 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionMatcherInterceptor.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionMatcherInterceptor.java @@ -178,6 +178,7 @@ public class SubscriptionMatcherInterceptor implements IResourceModifiedConsumer @VisibleForTesting public LinkedBlockingChannel getProcessingChannelForUnitTest() { + startIfNeeded(); return (LinkedBlockingChannel) myMatchingChannel; } } diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionValidatingInterceptor.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionValidatingInterceptor.java index eea9d895914..7ae4d70dd6f 100644 --- a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionValidatingInterceptor.java +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionValidatingInterceptor.java @@ -27,9 +27,10 @@ import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; import ca.uhn.fhir.jpa.model.entity.StorageSettings; import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc; -import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; import ca.uhn.fhir.jpa.subscription.match.matcher.matching.SubscriptionMatchingStrategy; import ca.uhn.fhir.jpa.subscription.match.matcher.matching.SubscriptionStrategyEvaluator; import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionCriteriaParser; @@ -38,7 +39,10 @@ import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscriptionChannelType; import ca.uhn.fhir.parser.DataFormatException; import ca.uhn.fhir.rest.api.EncodingEnum; +import ca.uhn.fhir.rest.api.server.IBundleProvider; import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.rest.param.UriParam; import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; @@ -46,10 +50,12 @@ import ca.uhn.fhir.util.HapiExtensions; import ca.uhn.fhir.util.SubscriptionUtil; import com.google.common.annotations.VisibleForTesting; import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r5.model.SubscriptionTopic; import org.springframework.beans.factory.annotation.Autowired; import java.net.URI; import java.net.URISyntaxException; +import java.util.Optional; import static org.apache.commons.lang3.StringUtils.isBlank; @Interceptor @@ -139,16 +145,23 @@ public class SubscriptionValidatingInterceptor { if (!finished) { - validateQuery(subscription.getCriteriaString(), "Subscription.criteria"); + if (subscription.isTopicSubscription()) { + Optional oTopic = findSubscriptionTopicByUrl(subscription.getCriteriaString()); + if (!oTopic.isPresent()) { + throw new UnprocessableEntityException(Msg.code(2322) + "No SubscriptionTopic exists with url: " + subscription.getCriteriaString()); + } + } else { + validateQuery(subscription.getCriteriaString(), "Subscription.criteria"); - if (subscription.getPayloadSearchCriteria() != null) { - validateQuery(subscription.getPayloadSearchCriteria(), "Subscription.extension(url='" + HapiExtensions.EXT_SUBSCRIPTION_PAYLOAD_SEARCH_CRITERIA + "')"); + if (subscription.getPayloadSearchCriteria() != null) { + validateQuery(subscription.getPayloadSearchCriteria(), "Subscription.extension(url='" + HapiExtensions.EXT_SUBSCRIPTION_PAYLOAD_SEARCH_CRITERIA + "')"); + } } validateChannelType(subscription); try { - SubscriptionMatchingStrategy strategy = mySubscriptionStrategyEvaluator.determineStrategy(subscription.getCriteriaString()); + SubscriptionMatchingStrategy strategy = mySubscriptionStrategyEvaluator.determineStrategy(subscription); mySubscriptionCanonicalizer.setMatchingStrategyTag(theSubscription, strategy); } catch (InvalidRequestException | DataFormatException e) { throw new UnprocessableEntityException(Msg.code(9) + "Invalid subscription criteria submitted: " + subscription.getCriteriaString() + " " + e.getMessage()); @@ -239,6 +252,15 @@ public class SubscriptionValidatingInterceptor { } + private Optional findSubscriptionTopicByUrl(String theCriteria) { + myDaoRegistry.getResourceDao("SubscriptionTopic"); + SearchParameterMap map = SearchParameterMap.newSynchronous(); + map.add(SubscriptionTopic.SP_URL, new UriParam(theCriteria)); + IFhirResourceDao subscriptionTopicDao = myDaoRegistry.getResourceDao("SubscriptionTopic"); + IBundleProvider search = subscriptionTopicDao.search(map, new SystemRequestDetails()); + return search.getResources(0, 1).stream().findFirst(); + } + public void validateMessageSubscriptionEndpoint(String theEndpointUrl) { if (theEndpointUrl == null) { throw new UnprocessableEntityException(Msg.code(16) + "No endpoint defined for message subscription"); diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/ActiveSubscriptionTopicCache.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/ActiveSubscriptionTopicCache.java new file mode 100644 index 00000000000..c6fb134ab3a --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/ActiveSubscriptionTopicCache.java @@ -0,0 +1,47 @@ +package ca.uhn.fhir.jpa.topic; + +import org.hl7.fhir.r5.model.SubscriptionTopic; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public class ActiveSubscriptionTopicCache { + // We canonicalize on R5 SubscriptionTopic and convert back to R4B when necessary + private final Map myCache = new ConcurrentHashMap<>(); + + public int size() { + return myCache.size(); + } + + /** + * @return true if the subscription topic was added, false if it was already present + */ + public boolean add(SubscriptionTopic theSubscriptionTopic) { + String key = theSubscriptionTopic.getIdElement().getIdPart(); + SubscriptionTopic previousValue = myCache.put(key, theSubscriptionTopic); + return previousValue == null; + } + + /** + * @return the number of entries removed + */ + public int removeIdsNotInCollection(Set theIdsToRetain) { + int retval = 0; + HashSet safeCopy = new HashSet<>(myCache.keySet()); + + for (String next : safeCopy) { + if (!theIdsToRetain.contains(next)) { + myCache.remove(next); + ++retval; + } + } + return retval; + } + + public Collection getAll() { + return myCache.values(); + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicConfig.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicConfig.java new file mode 100644 index 00000000000..20bcd19b9b9 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicConfig.java @@ -0,0 +1,41 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.searchparam.matcher.SearchParamMatcher; +import ca.uhn.fhir.jpa.subscription.channel.subscription.SubscriptionChannelRegistry; +import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionMatchDeliverer; +import org.springframework.context.annotation.Bean; + +public class SubscriptionTopicConfig { + @Bean + public SubscriptionMatchDeliverer subscriptionMatchDeliverer(FhirContext theFhirContext, IInterceptorBroadcaster theInterceptorBroadcaster, SubscriptionChannelRegistry theSubscriptionChannelRegistry) { + return new SubscriptionMatchDeliverer(theFhirContext, theInterceptorBroadcaster, theSubscriptionChannelRegistry); + } + + @Bean + public SubscriptionTopicMatchingSubscriber subscriptionTopicMatchingSubscriber(FhirContext theFhirContext) { + return new SubscriptionTopicMatchingSubscriber(theFhirContext); + } + + @Bean + public SubscriptionTopicPayloadBuilder subscriptionTopicPayloadBuilder(FhirContext theFhirContext) { + return new SubscriptionTopicPayloadBuilder(theFhirContext); + } + + @Bean + public SubscriptionTopicRegistry subscriptionTopicRegistry() { + return new SubscriptionTopicRegistry(); + } + + @Bean + public SubscriptionTopicSupport subscriptionTopicSupport(FhirContext theFhirContext, DaoRegistry theDaoRegistry, SearchParamMatcher theSearchParamMatcher) { + return new SubscriptionTopicSupport(theFhirContext, theDaoRegistry, theSearchParamMatcher); + } + + @Bean + public SubscriptionTopicLoader subscriptionTopicLoader() { + return new SubscriptionTopicLoader(); + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicLoader.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicLoader.java new file mode 100644 index 00000000000..bb1b7ed620b --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicLoader.java @@ -0,0 +1,119 @@ +/*- + * #%L + * HAPI FHIR Subscription Server + * %% + * Copyright (C) 2014 - 2023 Smile CDR, Inc. + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.cache.BaseResourceCacheSynchronizer; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionConstants; +import ca.uhn.fhir.rest.param.TokenParam; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r5.model.Enumerations; +import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; + +import javax.annotation.Nonnull; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + + +public class SubscriptionTopicLoader extends BaseResourceCacheSynchronizer { + private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionTopicLoader.class); + + @Autowired + private FhirContext myFhirContext; + @Autowired + private SubscriptionTopicRegistry mySubscriptionTopicRegistry; + + /** + * Constructor + */ + public SubscriptionTopicLoader() { + super("SubscriptionTopic"); + } + + @Override + public void registerListener() { + if (!myFhirContext.getVersion().getVersion().isEqualOrNewerThan(FhirVersionEnum.R4B)) { + return; + } + super.registerListener(); + } + + @Override + @Nonnull + protected SearchParameterMap getSearchParameterMap() { + SearchParameterMap map = new SearchParameterMap(); + + if (mySearchParamRegistry.getActiveSearchParam("SubscriptionTopic", "status") != null) { + map.add(SubscriptionTopic.SP_STATUS, new TokenParam(null, Enumerations.PublicationStatus.ACTIVE.toCode())); + } + map.setLoadSynchronousUpTo(SubscriptionConstants.MAX_SUBSCRIPTION_RESULTS); + return map; + } + + @Override + protected void handleInit(List resourceList) { + updateSubscriptionTopicRegistry(resourceList); + } + + @Override + protected int syncResourcesIntoCache(List resourceList) { + return updateSubscriptionTopicRegistry(resourceList); + } + + private int updateSubscriptionTopicRegistry(List theResourceList) { + Set allIds = new HashSet<>(); + int registeredCount = 0; + + for (IBaseResource resource : theResourceList) { + String nextId = resource.getIdElement().getIdPart(); + allIds.add(nextId); + + boolean registered = mySubscriptionTopicRegistry.register(normalizeToR5(resource)); + if (registered) { + registeredCount++; + } + } + + mySubscriptionTopicRegistry.unregisterAllIdsNotInCollection(allIds); + ourLog.debug("Finished sync subscription topics - registered {}", registeredCount); + return registeredCount; + } + + private SubscriptionTopic normalizeToR5(IBaseResource theResource) { + if (theResource instanceof SubscriptionTopic) { + return (SubscriptionTopic) theResource; + } else if (theResource instanceof org.hl7.fhir.r4b.model.SubscriptionTopic) { + return myFhirContext.newJsonParser().parseResource(SubscriptionTopic.class, FhirContext.forR4BCached().newJsonParser().encodeResourceToString(theResource)); + // WIP STR5 VersionConvertorFactory_43_50 when it supports SubscriptionTopic + // track here: https://github.com/hapifhir/org.hl7.fhir.core/issues/1212 +// return (SubscriptionTopic) VersionConvertorFactory_43_50.convertResource((org.hl7.fhir.r4b.model.SubscriptionTopic) theResource); + } else { + throw new IllegalArgumentException(Msg.code(2332) + "Only R4B and R5 SubscriptionTopic is currently supported. Found " + theResource.getClass()); + } + } +} + diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicMatcher.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicMatcher.java new file mode 100644 index 00000000000..9d74424815f --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicMatcher.java @@ -0,0 +1,37 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r5.model.SubscriptionTopic; + +import java.util.List; + +public class SubscriptionTopicMatcher { + private final SubscriptionTopicSupport mySubscriptionTopicSupport; + private final SubscriptionTopic myTopic; + + public SubscriptionTopicMatcher(SubscriptionTopicSupport theSubscriptionTopicSupport, SubscriptionTopic theTopic) { + mySubscriptionTopicSupport = theSubscriptionTopicSupport; + myTopic = theTopic; + } + + public InMemoryMatchResult match(ResourceModifiedMessage theMsg) { + IBaseResource resource = theMsg.getPayload(mySubscriptionTopicSupport.getFhirContext()); + String resourceName = resource.fhirType(); + + List triggers = myTopic.getResourceTrigger(); + for (SubscriptionTopic.SubscriptionTopicResourceTriggerComponent next : triggers) { + if (resourceName.equals(next.getResource())) { + SubscriptionTriggerMatcher matcher = new SubscriptionTriggerMatcher(mySubscriptionTopicSupport, theMsg, next); + InMemoryMatchResult result = matcher.match(); + if (result.matched()) { + return result; + } + // WIP STR5 should we check the other triggers? + } + } + // WIP STR5 add support for event triggers + return InMemoryMatchResult.noMatch(); + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicMatchingSubscriber.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicMatchingSubscriber.java new file mode 100644 index 00000000000..f583dc7b216 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicMatchingSubscriber.java @@ -0,0 +1,97 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.interceptor.api.HookParams; +import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult; +import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionMatchDeliverer; +import ca.uhn.fhir.jpa.subscription.match.registry.ActiveSubscription; +import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionRegistry; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedJsonMessage; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.MessagingException; + +import javax.annotation.Nonnull; +import java.util.Collection; +import java.util.List; + +public class SubscriptionTopicMatchingSubscriber implements MessageHandler { + private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionTopicMatchingSubscriber.class); + + private final FhirContext myFhirContext; + @Autowired + SubscriptionTopicSupport mySubscriptionTopicSupport; + @Autowired + SubscriptionTopicRegistry mySubscriptionTopicRegistry; + @Autowired + SubscriptionRegistry mySubscriptionRegistry; + @Autowired + SubscriptionMatchDeliverer mySubscriptionMatchDeliverer; + @Autowired + SubscriptionTopicPayloadBuilder mySubscriptionTopicPayloadBuilder; + @Autowired + private IInterceptorBroadcaster myInterceptorBroadcaster; + + public SubscriptionTopicMatchingSubscriber(FhirContext theFhirContext) { + myFhirContext = theFhirContext; + } + + @Override + public void handleMessage(@Nonnull Message theMessage) throws MessagingException { + ourLog.trace("Handling resource modified message: {}", theMessage); + + if (!(theMessage instanceof ResourceModifiedJsonMessage)) { + ourLog.warn("Unexpected message payload type: {}", theMessage); + return; + } + + ResourceModifiedMessage msg = ((ResourceModifiedJsonMessage) theMessage).getPayload(); + + // Interceptor call: SUBSCRIPTION_TOPIC_BEFORE_PERSISTED_RESOURCE_CHECKED + HookParams params = new HookParams() + .add(ResourceModifiedMessage.class, msg); + if (!myInterceptorBroadcaster.callHooks(Pointcut.SUBSCRIPTION_TOPIC_BEFORE_PERSISTED_RESOURCE_CHECKED, params)) { + return; + } + try { + matchActiveSubscriptionTopicsAndDeliver(msg); + } finally { + // Interceptor call: SUBSCRIPTION_TOPIC_AFTER_PERSISTED_RESOURCE_CHECKED + myInterceptorBroadcaster.callHooks(Pointcut.SUBSCRIPTION_TOPIC_AFTER_PERSISTED_RESOURCE_CHECKED, params); + } + } + + private void matchActiveSubscriptionTopicsAndDeliver(ResourceModifiedMessage theMsg) { + + Collection topics = mySubscriptionTopicRegistry.getAll(); + for (SubscriptionTopic topic : topics) { + SubscriptionTopicMatcher matcher = new SubscriptionTopicMatcher(mySubscriptionTopicSupport, topic); + InMemoryMatchResult result = matcher.match(theMsg); + if (result.matched()) { + ourLog.info("Matched topic {} to message {}", topic.getIdElement().toUnqualifiedVersionless(), theMsg); + deliverToTopicSubscriptions(theMsg, topic, result); + } + } + } + + private void deliverToTopicSubscriptions(ResourceModifiedMessage theMsg, SubscriptionTopic topic, InMemoryMatchResult result) { + List topicSubscriptions = mySubscriptionRegistry.getTopicSubscriptionsByUrl(topic.getUrl()); + if (!topicSubscriptions.isEmpty()) { + IBaseResource matchedResource = theMsg.getNewPayload(myFhirContext); + + for (ActiveSubscription activeSubscription : topicSubscriptions) { + // WIP STR5 apply subscription filter + IBaseResource payload = mySubscriptionTopicPayloadBuilder.buildPayload(matchedResource, theMsg, activeSubscription, topic); + mySubscriptionMatchDeliverer.deliverPayload(payload, theMsg, activeSubscription, result); + } + } + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilder.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilder.java new file mode 100644 index 00000000000..559c74ff185 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilder.java @@ -0,0 +1,71 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.context.FhirVersionEnum; +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.jpa.subscription.match.registry.ActiveSubscription; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import ca.uhn.fhir.util.BundleBuilder; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r5.model.Bundle; +import org.hl7.fhir.r5.model.Enumerations; +import org.hl7.fhir.r5.model.Reference; +import org.hl7.fhir.r5.model.SubscriptionStatus; +import org.hl7.fhir.r5.model.SubscriptionTopic; + +public class SubscriptionTopicPayloadBuilder { + private final FhirContext myFhirContext; + + public SubscriptionTopicPayloadBuilder(FhirContext theFhirContext) { + myFhirContext = theFhirContext; + } + + public IBaseResource buildPayload(IBaseResource theMatchedResource, ResourceModifiedMessage theMsg, ActiveSubscription theActiveSubscription, SubscriptionTopic theTopic) { + BundleBuilder bundleBuilder = new BundleBuilder(myFhirContext); + + // WIP STR5 set eventsSinceSubscriptionStart from the database + int eventsSinceSubscriptionStart = 1; + IBaseResource subscriptionStatus = buildSubscriptionStatus(theMatchedResource, theActiveSubscription, theTopic, eventsSinceSubscriptionStart); + + FhirVersionEnum fhirVersion = myFhirContext.getVersion().getVersion(); + + if (fhirVersion == FhirVersionEnum.R4B) { + bundleBuilder.setType(Bundle.BundleType.HISTORY.toCode()); + String serializedSubscriptionStatus = FhirContext.forR5Cached().newJsonParser().encodeResourceToString(subscriptionStatus); + subscriptionStatus = myFhirContext.newJsonParser().parseResource(org.hl7.fhir.r4b.model.SubscriptionStatus.class, serializedSubscriptionStatus); + // WIP STR5 VersionConvertorFactory_43_50 when it supports SubscriptionStatus + // track here: https://github.com/hapifhir/org.hl7.fhir.core/issues/1212 +// subscriptionStatus = (SubscriptionStatus) VersionConvertorFactory_43_50.convertResource((org.hl7.fhir.r4b.model.SubscriptionStatus) subscriptionStatus); + } else if (fhirVersion == FhirVersionEnum.R5) { + bundleBuilder.setType(Bundle.BundleType.SUBSCRIPTIONNOTIFICATION.toCode()); + } else { + throw new IllegalStateException(Msg.code(2331) + "SubscriptionTopic subscriptions are not supported on FHIR version: " + fhirVersion); + } + // WIP STR5 is this the right type of entry? + bundleBuilder.addCollectionEntry(subscriptionStatus); + switch (theMsg.getOperationType()) { + case CREATE: + bundleBuilder.addTransactionCreateEntry(theMatchedResource); + break; + case UPDATE: + bundleBuilder.addTransactionUpdateEntry(theMatchedResource); + break; + case DELETE: + bundleBuilder.addTransactionDeleteEntry(theMatchedResource); + break; + } + return bundleBuilder.getBundle(); + } + + private SubscriptionStatus buildSubscriptionStatus(IBaseResource theMatchedResource, ActiveSubscription theActiveSubscription, SubscriptionTopic theTopic, int theEventsSinceSubscriptionStart) { + SubscriptionStatus subscriptionStatus = new SubscriptionStatus(); + subscriptionStatus.setStatus(Enumerations.SubscriptionStatusCodes.ACTIVE); + subscriptionStatus.setType(SubscriptionStatus.SubscriptionNotificationType.EVENTNOTIFICATION); + // WIP STR5 count events since subscription start and set eventsSinceSubscriptionStart + subscriptionStatus.setEventsSinceSubscriptionStart(theEventsSinceSubscriptionStart); + subscriptionStatus.addNotificationEvent().setEventNumber(theEventsSinceSubscriptionStart).setFocus(new Reference(theMatchedResource.getIdElement())); + subscriptionStatus.setSubscription(new Reference(theActiveSubscription.getSubscription().getIdElement(myFhirContext))); + subscriptionStatus.setTopic(theTopic.getUrl()); + return subscriptionStatus; + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicRegistry.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicRegistry.java new file mode 100644 index 00000000000..e94ff0ef9fc --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicRegistry.java @@ -0,0 +1,29 @@ +package ca.uhn.fhir.jpa.topic; + +import org.hl7.fhir.r5.model.SubscriptionTopic; + +import java.util.Collection; +import java.util.Set; + +public class SubscriptionTopicRegistry { + private final ActiveSubscriptionTopicCache myActiveSubscriptionTopicCache = new ActiveSubscriptionTopicCache(); + + public SubscriptionTopicRegistry() { + } + + public int size() { + return myActiveSubscriptionTopicCache.size(); + } + + public boolean register(SubscriptionTopic resource) { + return myActiveSubscriptionTopicCache.add(resource); + } + + public void unregisterAllIdsNotInCollection(Set theIdsToRetain) { + myActiveSubscriptionTopicCache.removeIdsNotInCollection(theIdsToRetain); + } + + public Collection getAll() { + return myActiveSubscriptionTopicCache.getAll(); + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicSupport.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicSupport.java new file mode 100644 index 00000000000..a24bfcd0764 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicSupport.java @@ -0,0 +1,29 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.searchparam.matcher.SearchParamMatcher; + +public class SubscriptionTopicSupport { + private final FhirContext myFhirContext; + private final DaoRegistry myDaoRegistry; + private final SearchParamMatcher mySearchParamMatcher; + + public SubscriptionTopicSupport(FhirContext theFhirContext, DaoRegistry theDaoRegistry, SearchParamMatcher theSearchParamMatcher) { + myFhirContext = theFhirContext; + myDaoRegistry = theDaoRegistry; + mySearchParamMatcher = theSearchParamMatcher; + } + + public FhirContext getFhirContext() { + return myFhirContext; + } + + public DaoRegistry getDaoRegistry() { + return myDaoRegistry; + } + + public SearchParamMatcher getSearchParamMatcher() { + return mySearchParamMatcher; + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicUtil.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicUtil.java new file mode 100644 index 00000000000..66f9e8dd4cb --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicUtil.java @@ -0,0 +1,24 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.rest.server.messaging.BaseResourceMessage; +import org.hl7.fhir.r5.model.Enumeration; +import org.hl7.fhir.r5.model.SubscriptionTopic; + +import java.util.List; + +public class SubscriptionTopicUtil { + public static boolean matches(BaseResourceMessage.OperationTypeEnum theOperationType, List> theSupportedInteractions) { + for (Enumeration next : theSupportedInteractions) { + if (next.getValue() == SubscriptionTopic.InteractionTrigger.CREATE && theOperationType == BaseResourceMessage.OperationTypeEnum.CREATE) { + return true; + } + if (next.getValue() == SubscriptionTopic.InteractionTrigger.UPDATE && theOperationType == BaseResourceMessage.OperationTypeEnum.UPDATE) { + return true; + } + if (next.getValue() == SubscriptionTopic.InteractionTrigger.DELETE && theOperationType == BaseResourceMessage.OperationTypeEnum.DELETE) { + return true; + } + } + return false; + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTriggerMatcher.java b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTriggerMatcher.java new file mode 100644 index 00000000000..4b8503c2fa4 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/main/java/ca/uhn/fhir/jpa/topic/SubscriptionTriggerMatcher.java @@ -0,0 +1,87 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.rest.server.messaging.BaseResourceMessage; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r5.model.Enumeration; +import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +public class SubscriptionTriggerMatcher { + private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionTriggerMatcher.class); + private final SubscriptionTopicSupport mySubscriptionTopicSupport; + private final BaseResourceMessage.OperationTypeEnum myOperation; + private final SubscriptionTopic.SubscriptionTopicResourceTriggerComponent myTrigger; + private final String myResourceName; + private final IBaseResource myResource; + private final IFhirResourceDao myDao; + private final SystemRequestDetails mySrd; + + public SubscriptionTriggerMatcher(SubscriptionTopicSupport theSubscriptionTopicSupport, ResourceModifiedMessage theMsg, SubscriptionTopic.SubscriptionTopicResourceTriggerComponent theTrigger) { + mySubscriptionTopicSupport = theSubscriptionTopicSupport; + myOperation = theMsg.getOperationType(); + myResource = theMsg.getPayload(theSubscriptionTopicSupport.getFhirContext()); + myResourceName = myResource.fhirType(); + myDao = mySubscriptionTopicSupport.getDaoRegistry().getResourceDao(myResourceName); + myTrigger = theTrigger; + mySrd = new SystemRequestDetails(); + } + + public InMemoryMatchResult match() { + List> supportedInteractions = myTrigger.getSupportedInteraction(); + if (SubscriptionTopicUtil.matches(myOperation, supportedInteractions)) { + SubscriptionTopic.SubscriptionTopicResourceTriggerQueryCriteriaComponent queryCriteria = myTrigger.getQueryCriteria(); + InMemoryMatchResult result = match(queryCriteria); + if (result.matched()) { + return result; + } + } + return InMemoryMatchResult.noMatch(); + } + + private InMemoryMatchResult match(SubscriptionTopic.SubscriptionTopicResourceTriggerQueryCriteriaComponent theQueryCriteria) { + InMemoryMatchResult previousMatches = InMemoryMatchResult.successfulMatch(); + InMemoryMatchResult currentMatches = InMemoryMatchResult.successfulMatch(); + String previousCriteria = theQueryCriteria.getPrevious(); + String currentCriteria = theQueryCriteria.getCurrent(); + + if (previousCriteria != null) { + if (myOperation == ResourceModifiedMessage.OperationTypeEnum.UPDATE || + myOperation == ResourceModifiedMessage.OperationTypeEnum.DELETE) { + Long currentVersion = myResource.getIdElement().getVersionIdPartAsLong(); + if (currentVersion > 1) { + IIdType previousVersionId = myResource.getIdElement().withVersion("" + (currentVersion - 1)); + // WIP STR5 should we use the partition id from the resource? Ideally we should have a "previous version" service we can use for this + IBaseResource previousVersion = myDao.read(previousVersionId, new SystemRequestDetails()); + previousMatches = matchResource(previousVersion, previousCriteria); + } else { + ourLog.warn("Resource {} has a version of 1, which should not be the case for a create or delete operation", myResource.getIdElement().toUnqualifiedVersionless()); + } + } + } + if (currentCriteria != null) { + currentMatches = matchResource(myResource, currentCriteria); + } + // WIP STR5 is this the correct interpretation of requireBoth? + if (theQueryCriteria.getRequireBoth()) { + return InMemoryMatchResult.and(previousMatches, currentMatches); + } else { + return InMemoryMatchResult.or(previousMatches, currentMatches); + } + } + + private InMemoryMatchResult matchResource(IBaseResource theResource, String theCriteria) { + InMemoryMatchResult result = mySubscriptionTopicSupport.getSearchParamMatcher().match(theCriteria, theResource, mySrd); + if (!result.supported()) { + ourLog.warn("Subscription topic {} has a query criteria that is not supported in-memory: {}", myTrigger.getId(), theCriteria); + } + return result; + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/match/registry/ActiveSubscriptionCacheTest.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/match/registry/ActiveSubscriptionCacheTest.java index 0d8e9c82e6a..33607879a2a 100644 --- a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/match/registry/ActiveSubscriptionCacheTest.java +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/match/registry/ActiveSubscriptionCacheTest.java @@ -2,21 +2,26 @@ package ca.uhn.fhir.jpa.subscription.match.registry; import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; import ca.uhn.fhir.model.primitive.IdDt; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.List; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; public class ActiveSubscriptionCacheTest { static final String ID1 = "id1"; static final String ID2 = "id2"; + static final String ID3 = "id3"; + public static final String TEST_TOPIC_URL = "http://test.topic"; + public static final String TEST_TOPIC_URL_OTHER = "http://test.topic.other"; @Test public void twoPhaseDelete() { @@ -103,4 +108,36 @@ public class ActiveSubscriptionCacheTest { assertFalse(activeSub2.isFlagForDeletion()); } + @Test + public void getTopicSubscriptionsForUrl() { + ActiveSubscriptionCache activeSubscriptionCache = new ActiveSubscriptionCache(); + ActiveSubscription activeSub1 = buildActiveSubscription(ID1); + activeSubscriptionCache.put(ID1, activeSub1); + assertThat(activeSubscriptionCache.getTopicSubscriptionsForUrl(TEST_TOPIC_URL), hasSize(0)); + + ActiveSubscription activeSub2 = buildTopicSubscription(ID2, TEST_TOPIC_URL); + activeSubscriptionCache.put(ID2, activeSub2); + assertThat(activeSubscriptionCache.getTopicSubscriptionsForUrl(TEST_TOPIC_URL), hasSize(1)); + ActiveSubscription match = activeSubscriptionCache.getTopicSubscriptionsForUrl(TEST_TOPIC_URL).get(0); + assertEquals(ID2, match.getId()); + + ActiveSubscription activeSub3 = buildTopicSubscription(ID3, TEST_TOPIC_URL_OTHER); + activeSubscriptionCache.put(ID3, activeSub3); + assertThat(activeSubscriptionCache.getTopicSubscriptionsForUrl(TEST_TOPIC_URL), hasSize(1)); + match = activeSubscriptionCache.getTopicSubscriptionsForUrl(TEST_TOPIC_URL).get(0); + assertEquals(ID2, match.getId()); + + assertThat(activeSubscriptionCache.getTopicSubscriptionsForUrl(TEST_TOPIC_URL_OTHER), hasSize(1)); + match = activeSubscriptionCache.getTopicSubscriptionsForUrl(TEST_TOPIC_URL_OTHER).get(0); + assertEquals(ID3, match.getId()); + } + + @NotNull + private ActiveSubscription buildTopicSubscription(String theId, String theTopicUrl) { + ActiveSubscription activeSub2 = buildActiveSubscription(theId); + activeSub2.getSubscription().setTopicSubscription(true); + activeSub2.getSubscription().setCriteriaString(theTopicUrl); + return activeSub2; + } + } diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionLoaderTest.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionLoaderTest.java index b0fe98e11f3..5d9eac20df4 100644 --- a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionLoaderTest.java +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionLoaderTest.java @@ -6,10 +6,10 @@ import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; import ca.uhn.fhir.jpa.cache.IResourceChangeListenerCache; import ca.uhn.fhir.jpa.cache.IResourceChangeListenerRegistry; import ca.uhn.fhir.jpa.model.sched.ISchedulerService; -import ca.uhn.fhir.rest.api.server.SystemRequestDetails; import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionActivatingSubscriber; import ca.uhn.fhir.rest.api.server.IBundleProvider; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; import ca.uhn.fhir.rest.server.SimpleBundleProvider; import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; import ch.qos.logback.classic.Level; @@ -36,7 +36,6 @@ import java.util.List; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -55,7 +54,7 @@ public class SubscriptionLoaderTest { private SubscriptionRegistry mySubscriptionRegistry; @Mock - private DaoRegistry myDaoRegistery; + private DaoRegistry myDaoRegistry; @Mock private SubscriptionActivatingSubscriber mySubscriptionActivatingInterceptor; @@ -76,6 +75,8 @@ public class SubscriptionLoaderTest { @Mock private SubscriptionCanonicalizer mySubscriptionCanonicalizer; + @Mock + private IFhirResourceDao mySubscriptionDao; @InjectMocks private SubscriptionLoader mySubscriptionLoader; @@ -95,6 +96,8 @@ public class SubscriptionLoaderTest { anyLong() )).thenReturn(mySubscriptionCache); + when(myDaoRegistry.getResourceDaoOrNull("Subscription")).thenReturn(mySubscriptionDao); + mySubscriptionLoader.registerListener(); } @@ -117,16 +120,15 @@ public class SubscriptionLoaderTest { Subscription subscription = new Subscription(); subscription.setId("Subscription/123"); subscription.setError("THIS IS AN ERROR"); - IFhirResourceDao subscriptionDao = mock(IFhirResourceDao.class); ourLogger.setLevel(Level.ERROR); // when - when(myDaoRegistery.getSubscriptionDao()) - .thenReturn(subscriptionDao); - when(myDaoRegistery.isResourceTypeSupported(anyString())) + when(myDaoRegistry.getResourceDao("Subscription")) + .thenReturn(mySubscriptionDao); + when(myDaoRegistry.isResourceTypeSupported("Subscription")) .thenReturn(true); - when(subscriptionDao.search(any(SearchParameterMap.class), any(SystemRequestDetails.class))) + when(mySubscriptionDao.search(any(SearchParameterMap.class), any(SystemRequestDetails.class))) .thenReturn(getSubscriptionList( Collections.singletonList(subscription) )); @@ -140,10 +142,10 @@ public class SubscriptionLoaderTest { when(mySubscriptionCanonicalizer.getSubscriptionStatus(any())).thenReturn(SubscriptionConstants.REQUESTED_STATUS); // test - mySubscriptionLoader.syncSubscriptions(); + mySubscriptionLoader.syncDatabaseToCache(); // verify - verify(subscriptionDao) + verify(mySubscriptionDao) .search(any(SearchParameterMap.class), any(SystemRequestDetails.class)); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ILoggingEvent.class); diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/module/cache/SubscriptionLoaderTest.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/module/cache/SubscriptionLoaderTest.java index bf123afd931..16da36a3c0f 100644 --- a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/module/cache/SubscriptionLoaderTest.java +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/module/cache/SubscriptionLoaderTest.java @@ -2,18 +2,11 @@ package ca.uhn.fhir.jpa.subscription.module.cache; import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionLoader; import ca.uhn.fhir.jpa.subscription.module.standalone.BaseBlockingQueueSubscribableChannelDstu3Test; -import org.hl7.fhir.dstu3.model.Subscription; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import static org.junit.jupiter.api.Assertions.assertEquals; - public class SubscriptionLoaderTest extends BaseBlockingQueueSubscribableChannelDstu3Test { @Test public void testMultipleThreadsDontBlock() throws InterruptedException { @@ -29,6 +22,6 @@ public class SubscriptionLoaderTest extends BaseBlockingQueueSubscribableChannel }).start(); latch.await(10, TimeUnit.SECONDS); - svc.syncSubscriptions(); + svc.syncDatabaseToCache(); } } diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/module/subscriber/SubscriptionMatchingSubscriberTest.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/module/subscriber/SubscriptionMatchingSubscriberTest.java index fecdfdceb98..12f19bc7d9a 100644 --- a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/module/subscriber/SubscriptionMatchingSubscriberTest.java +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/module/subscriber/SubscriptionMatchingSubscriberTest.java @@ -7,6 +7,7 @@ import ca.uhn.fhir.interceptor.model.RequestPartitionId; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionCriteriaParser; +import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionMatchDeliverer; import ca.uhn.fhir.jpa.subscription.match.matcher.subscriber.SubscriptionMatchingSubscriber; import ca.uhn.fhir.jpa.subscription.match.registry.ActiveSubscription; import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionRegistry; @@ -26,9 +27,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Answers; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; -import org.springframework.test.util.ReflectionTestUtils; import java.util.Collections; import java.util.List; @@ -405,7 +406,6 @@ public class SubscriptionMatchingSubscriberTest extends BaseBlockingQueueSubscri @Nested public class TestDeleteMessages { - private final SubscriptionMatchingSubscriber subscriber = new SubscriptionMatchingSubscriber(); @Mock ResourceModifiedMessage message; @Mock @@ -422,11 +422,14 @@ public class SubscriptionMatchingSubscriberTest extends BaseBlockingQueueSubscri CanonicalSubscription myNonDeleteCanonicalSubscription; @Mock SubscriptionCriteriaParser.SubscriptionCriteria mySubscriptionCriteria; + @Mock + SubscriptionMatchDeliverer mySubscriptionMatchDeliverer; + @InjectMocks + SubscriptionMatchingSubscriber subscriber; + @Test public void testAreNotIgnored() { - ReflectionTestUtils.setField(subscriber, "myInterceptorBroadcaster", myInterceptorBroadcaster); - ReflectionTestUtils.setField(subscriber, "mySubscriptionRegistry", mySubscriptionRegistry); when(message.getOperationType()).thenReturn(BaseResourceModifiedMessage.OperationTypeEnum.DELETE); when(myInterceptorBroadcaster.callHooks( @@ -443,9 +446,6 @@ public class SubscriptionMatchingSubscriberTest extends BaseBlockingQueueSubscri @Test public void matchActiveSubscriptionsChecksSendDeleteMessagesExtensionFlag() { - ReflectionTestUtils.setField(subscriber, "myInterceptorBroadcaster", myInterceptorBroadcaster); - ReflectionTestUtils.setField(subscriber, "mySubscriptionRegistry", mySubscriptionRegistry); - when(message.getOperationType()).thenReturn(BaseResourceModifiedMessage.OperationTypeEnum.DELETE); when(myInterceptorBroadcaster.callHooks( eq(Pointcut.SUBSCRIPTION_BEFORE_PERSISTED_RESOURCE_CHECKED), any(HookParams.class))).thenReturn(true); @@ -463,9 +463,6 @@ public class SubscriptionMatchingSubscriberTest extends BaseBlockingQueueSubscri @Test public void testMultipleSubscriptionsDoNotEarlyReturn() { - ReflectionTestUtils.setField(subscriber, "myInterceptorBroadcaster", myInterceptorBroadcaster); - ReflectionTestUtils.setField(subscriber, "mySubscriptionRegistry", mySubscriptionRegistry); - when(message.getOperationType()).thenReturn(BaseResourceModifiedMessage.OperationTypeEnum.DELETE); when(myInterceptorBroadcaster.callHooks( eq(Pointcut.SUBSCRIPTION_BEFORE_PERSISTED_RESOURCE_CHECKED), any(HookParams.class))).thenReturn(true); @@ -488,9 +485,6 @@ public class SubscriptionMatchingSubscriberTest extends BaseBlockingQueueSubscri @Test public void matchActiveSubscriptionsAndDeliverSetsPartitionId() { - ReflectionTestUtils.setField(subscriber, "myInterceptorBroadcaster", myInterceptorBroadcaster); - ReflectionTestUtils.setField(subscriber, "mySubscriptionRegistry", mySubscriptionRegistry); - when(message.getOperationType()).thenReturn(BaseResourceModifiedMessage.OperationTypeEnum.DELETE); when(myInterceptorBroadcaster.callHooks( eq(Pointcut.SUBSCRIPTION_BEFORE_PERSISTED_RESOURCE_CHECKED), any(HookParams.class))).thenReturn(true); diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionValidatingInterceptorTest.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionValidatingInterceptorTest.java index 211b38ad783..bcb5f18840c 100644 --- a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionValidatingInterceptorTest.java +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/subscription/submit/interceptor/SubscriptionValidatingInterceptorTest.java @@ -5,15 +5,21 @@ import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc; import ca.uhn.fhir.jpa.subscription.match.matcher.matching.SubscriptionStrategyEvaluator; import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionCanonicalizer; -import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; +import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionConstants; +import ca.uhn.fhir.rest.server.SimpleBundleProvider; import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; -import org.hl7.fhir.r4.model.Subscription; +import org.hl7.fhir.r4b.model.CanonicalType; +import org.hl7.fhir.r4b.model.Enumerations; +import org.hl7.fhir.r4b.model.Subscription; +import org.hl7.fhir.r4b.model.SubscriptionTopic; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -23,6 +29,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.test.context.junit.jupiter.SpringExtension; import javax.annotation.Nonnull; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.List; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; @@ -33,6 +43,7 @@ import static org.mockito.Mockito.when; @ExtendWith(SpringExtension.class) public class SubscriptionValidatingInterceptorTest { private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionValidatingInterceptorTest.class); + public static final String TEST_SUBSCRIPTION_TOPIC_URL = "http://test.topic"; @Autowired private SubscriptionValidatingInterceptor mySubscriptionValidatingInterceptor; @@ -44,6 +55,8 @@ public class SubscriptionValidatingInterceptorTest { private JpaStorageSettings myStorageSettings; @MockBean private IRequestPartitionHelperSvc myRequestPartitionHelperSvc; + @Mock + private IFhirResourceDao mySubscriptionTopicDao; @BeforeEach public void before() { @@ -66,7 +79,7 @@ public class SubscriptionValidatingInterceptorTest { public void testEmptyStatus() { try { Subscription badSub = new Subscription(); - badSub.setStatus(Subscription.SubscriptionStatus.ACTIVE); + badSub.setStatus(Enumerations.SubscriptionStatus.ACTIVE); mySubscriptionValidatingInterceptor.resourcePreCreate(badSub, null, null); fail(); } catch (UnprocessableEntityException e) { @@ -78,7 +91,7 @@ public class SubscriptionValidatingInterceptorTest { public void testBadCriteria() { try { Subscription badSub = new Subscription(); - badSub.setStatus(Subscription.SubscriptionStatus.ACTIVE); + badSub.setStatus(Enumerations.SubscriptionStatus.ACTIVE); badSub.setCriteria("Patient"); mySubscriptionValidatingInterceptor.resourcePreCreate(badSub, null, null); fail(); @@ -91,7 +104,7 @@ public class SubscriptionValidatingInterceptorTest { public void testBadChannel() { try { Subscription badSub = new Subscription(); - badSub.setStatus(Subscription.SubscriptionStatus.ACTIVE); + badSub.setStatus(Enumerations.SubscriptionStatus.ACTIVE); badSub.setCriteria("Patient?"); mySubscriptionValidatingInterceptor.resourcePreCreate(badSub, null, null); fail(); @@ -104,7 +117,7 @@ public class SubscriptionValidatingInterceptorTest { public void testEmptyEndpoint() { try { Subscription badSub = new Subscription(); - badSub.setStatus(Subscription.SubscriptionStatus.ACTIVE); + badSub.setStatus(Enumerations.SubscriptionStatus.ACTIVE); badSub.setCriteria("Patient?"); Subscription.SubscriptionChannelComponent channel = badSub.getChannel(); channel.setType(Subscription.SubscriptionChannelType.MESSAGE); @@ -118,7 +131,7 @@ public class SubscriptionValidatingInterceptorTest { @Test public void testMalformedEndpoint() { Subscription badSub = new Subscription(); - badSub.setStatus(Subscription.SubscriptionStatus.ACTIVE); + badSub.setStatus(Enumerations.SubscriptionStatus.ACTIVE); badSub.setCriteria("Patient?"); Subscription.SubscriptionChannelComponent channel = badSub.getChannel(); channel.setType(Subscription.SubscriptionChannelType.MESSAGE); @@ -170,11 +183,40 @@ public class SubscriptionValidatingInterceptorTest { } } + @Test + public void testInvalidTopic() throws URISyntaxException { + when(myDaoRegistry.getResourceDao("SubscriptionTopic")).thenReturn(mySubscriptionTopicDao); + + SimpleBundleProvider emptyBundleProvider = new SimpleBundleProvider(Collections.emptyList()); + when(mySubscriptionTopicDao.search(any(), any())).thenReturn(emptyBundleProvider); + + org.hl7.fhir.r4b.model.Subscription badSub = new org.hl7.fhir.r4b.model.Subscription(); + badSub.setStatus(Enumerations.SubscriptionStatus.ACTIVE); + badSub.getMeta().getProfile().add(new CanonicalType(new URI("http://other.profile"))); + badSub.getMeta().getProfile().add(new CanonicalType(new URI(SubscriptionConstants.SUBSCRIPTION_TOPIC_PROFILE_URL))); + badSub.setCriteria("http://topic.url"); + Subscription.SubscriptionChannelComponent channel = badSub.getChannel(); + channel.setType(Subscription.SubscriptionChannelType.MESSAGE); + channel.setEndpoint("channel:my-queue-name"); + try { + mySubscriptionValidatingInterceptor.validateSubmittedSubscription(badSub, null, null, Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED); + fail(); + } catch (UnprocessableEntityException e) { + assertThat(e.getMessage(), is(Msg.code(2322) + "No SubscriptionTopic exists with url: http://topic.url")); + } + + // Happy path + SubscriptionTopic topic = new SubscriptionTopic(); + SimpleBundleProvider simpleBundleProvider = new SimpleBundleProvider(List.of(topic)); + when(mySubscriptionTopicDao.search(any(), any())).thenReturn(simpleBundleProvider); + mySubscriptionValidatingInterceptor.validateSubmittedSubscription(badSub, null, null, Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED); + } + @Configuration public static class SpringConfig { @Bean FhirContext fhirContext() { - return FhirContext.forR4(); + return FhirContext.forR4B(); } @Bean @@ -191,7 +233,7 @@ public class SubscriptionValidatingInterceptorTest { @Nonnull private static Subscription createSubscription() { final Subscription subscription = new Subscription(); - subscription.setStatus(Subscription.SubscriptionStatus.REQUESTED); + subscription.setStatus(Enumerations.SubscriptionStatus.REQUESTED); subscription.setCriteria("Patient?"); final Subscription.SubscriptionChannelComponent channel = subscription.getChannel(); channel.setType(Subscription.SubscriptionChannelType.RESTHOOK); diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/ActiveSubscriptionTopicCacheTest.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/ActiveSubscriptionTopicCacheTest.java new file mode 100644 index 00000000000..7d753a766c2 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/ActiveSubscriptionTopicCacheTest.java @@ -0,0 +1,38 @@ +package ca.uhn.fhir.jpa.topic; + +import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ActiveSubscriptionTopicCacheTest { + @Test + public void testOperations() { + var cache = new ActiveSubscriptionTopicCache(); + SubscriptionTopic topic1 = new SubscriptionTopic(); + topic1.setId("1"); + cache.add(topic1); + assertThat(cache.getAll(), hasSize(1)); + assertEquals(1, cache.size()); + assertEquals("1", cache.getAll().iterator().next().getId()); + + SubscriptionTopic topic2 = new SubscriptionTopic(); + topic2.setId("2"); + cache.add(topic2); + + SubscriptionTopic topic3 = new SubscriptionTopic(); + topic3.setId("3"); + cache.add(topic3); + + assertEquals(3, cache.size()); + + Set idsToKeep = Set.of("1", "3"); + int removed = cache.removeIdsNotInCollection(idsToKeep); + assertEquals(1, removed); + assertEquals(2, cache.size()); + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilderR4BTest.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilderR4BTest.java new file mode 100644 index 00000000000..c6d86f2bda1 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilderR4BTest.java @@ -0,0 +1,91 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.subscription.match.registry.ActiveSubscription; +import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import ca.uhn.fhir.rest.server.messaging.BaseResourceMessage; +import ca.uhn.fhir.util.BundleUtil; +import org.hl7.fhir.r4b.model.Bundle; +import org.hl7.fhir.r4b.model.Encounter; +import org.hl7.fhir.r4b.model.Resource; +import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SubscriptionTopicPayloadBuilderR4BTest { + FhirContext ourFhirContext = FhirContext.forR4BCached(); + @Test + public void testBuildPayloadDelete() { + // setup + var svc = new SubscriptionTopicPayloadBuilder(ourFhirContext); + var encounter = new Encounter(); + encounter.setId("Encounter/1"); + ResourceModifiedMessage msg = new ResourceModifiedMessage(); + CanonicalSubscription sub = new CanonicalSubscription(); + ActiveSubscription subscription = new ActiveSubscription(sub, "test"); + SubscriptionTopic topic = new SubscriptionTopic(); + msg.setOperationType(BaseResourceMessage.OperationTypeEnum.DELETE); + + // run + Bundle payload = (Bundle)svc.buildPayload(encounter, msg, subscription, topic); + + // verify + List resources = BundleUtil.toListOfResourcesOfType(ourFhirContext, payload, Resource.class); + assertEquals(1, resources.size()); + assertEquals("SubscriptionStatus", resources.get(0).getResourceType().name()); + + assertEquals(Bundle.HTTPVerb.DELETE, payload.getEntry().get(1).getRequest().getMethod()); + } + + @Test + public void testBuildPayloadUpdate() { + // setup + var svc = new SubscriptionTopicPayloadBuilder(ourFhirContext); + var encounter = new Encounter(); + encounter.setId("Encounter/1"); + ResourceModifiedMessage msg = new ResourceModifiedMessage(); + CanonicalSubscription sub = new CanonicalSubscription(); + ActiveSubscription subscription = new ActiveSubscription(sub, "test"); + SubscriptionTopic topic = new SubscriptionTopic(); + msg.setOperationType(BaseResourceMessage.OperationTypeEnum.UPDATE); + + // run + Bundle payload = (Bundle)svc.buildPayload(encounter, msg, subscription, topic); + + // verify + List resources = BundleUtil.toListOfResourcesOfType(ourFhirContext, payload, Resource.class); + assertEquals(2, resources.size()); + assertEquals("SubscriptionStatus", resources.get(0).getResourceType().name()); + assertEquals("Encounter", resources.get(1).getResourceType().name()); + + assertEquals(Bundle.HTTPVerb.PUT, payload.getEntry().get(1).getRequest().getMethod()); + } + + @Test + public void testBuildPayloadCreate() { + // setup + var svc = new SubscriptionTopicPayloadBuilder(ourFhirContext); + var encounter = new Encounter(); + encounter.setId("Encounter/1"); + ResourceModifiedMessage msg = new ResourceModifiedMessage(); + CanonicalSubscription sub = new CanonicalSubscription(); + ActiveSubscription subscription = new ActiveSubscription(sub, "test"); + SubscriptionTopic topic = new SubscriptionTopic(); + msg.setOperationType(BaseResourceMessage.OperationTypeEnum.CREATE); + + // run + Bundle payload = (Bundle)svc.buildPayload(encounter, msg, subscription, topic); + + // verify + List resources = BundleUtil.toListOfResourcesOfType(ourFhirContext, payload, Resource.class); + assertEquals(2, resources.size()); + assertEquals("SubscriptionStatus", resources.get(0).getResourceType().name()); + assertEquals("Encounter", resources.get(1).getResourceType().name()); + + assertEquals(Bundle.HTTPVerb.POST, payload.getEntry().get(1).getRequest().getMethod()); + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilderR5Test.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilderR5Test.java new file mode 100644 index 00000000000..486bebdb72e --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicPayloadBuilderR5Test.java @@ -0,0 +1,91 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.subscription.match.registry.ActiveSubscription; +import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import ca.uhn.fhir.rest.server.messaging.BaseResourceMessage; +import ca.uhn.fhir.util.BundleUtil; +import org.hl7.fhir.r5.model.Bundle; +import org.hl7.fhir.r5.model.Encounter; +import org.hl7.fhir.r5.model.Resource; +import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SubscriptionTopicPayloadBuilderR5Test { + FhirContext ourFhirContext = FhirContext.forR5Cached(); + @Test + public void testBuildPayloadDelete() { + // setup + var svc = new SubscriptionTopicPayloadBuilder(ourFhirContext); + var encounter = new Encounter(); + encounter.setId("Encounter/1"); + ResourceModifiedMessage msg = new ResourceModifiedMessage(); + CanonicalSubscription sub = new CanonicalSubscription(); + ActiveSubscription subscription = new ActiveSubscription(sub, "test"); + SubscriptionTopic topic = new SubscriptionTopic(); + msg.setOperationType(BaseResourceMessage.OperationTypeEnum.DELETE); + + // run + Bundle payload = (Bundle)svc.buildPayload(encounter, msg, subscription, topic); + + // verify + List resources = BundleUtil.toListOfResourcesOfType(ourFhirContext, payload, Resource.class); + assertEquals(1, resources.size()); + assertEquals("SubscriptionStatus", resources.get(0).getResourceType().name()); + + assertEquals(Bundle.HTTPVerb.DELETE, payload.getEntry().get(1).getRequest().getMethod()); + } + + @Test + public void testBuildPayloadUpdate() { + // setup + var svc = new SubscriptionTopicPayloadBuilder(ourFhirContext); + var encounter = new Encounter(); + encounter.setId("Encounter/1"); + ResourceModifiedMessage msg = new ResourceModifiedMessage(); + CanonicalSubscription sub = new CanonicalSubscription(); + ActiveSubscription subscription = new ActiveSubscription(sub, "test"); + SubscriptionTopic topic = new SubscriptionTopic(); + msg.setOperationType(BaseResourceMessage.OperationTypeEnum.UPDATE); + + // run + Bundle payload = (Bundle)svc.buildPayload(encounter, msg, subscription, topic); + + // verify + List resources = BundleUtil.toListOfResourcesOfType(ourFhirContext, payload, Resource.class); + assertEquals(2, resources.size()); + assertEquals("SubscriptionStatus", resources.get(0).getResourceType().name()); + assertEquals("Encounter", resources.get(1).getResourceType().name()); + + assertEquals(Bundle.HTTPVerb.PUT, payload.getEntry().get(1).getRequest().getMethod()); + } + + @Test + public void testBuildPayloadCreate() { + // setup + var svc = new SubscriptionTopicPayloadBuilder(ourFhirContext); + var encounter = new Encounter(); + encounter.setId("Encounter/1"); + ResourceModifiedMessage msg = new ResourceModifiedMessage(); + CanonicalSubscription sub = new CanonicalSubscription(); + ActiveSubscription subscription = new ActiveSubscription(sub, "test"); + SubscriptionTopic topic = new SubscriptionTopic(); + msg.setOperationType(BaseResourceMessage.OperationTypeEnum.CREATE); + + // run + Bundle payload = (Bundle)svc.buildPayload(encounter, msg, subscription, topic); + + // verify + List resources = BundleUtil.toListOfResourcesOfType(ourFhirContext, payload, Resource.class); + assertEquals(2, resources.size()); + assertEquals("SubscriptionStatus", resources.get(0).getResourceType().name()); + assertEquals("Encounter", resources.get(1).getResourceType().name()); + + assertEquals(Bundle.HTTPVerb.POST, payload.getEntry().get(1).getRequest().getMethod()); + } +} diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicUtilTest.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicUtilTest.java new file mode 100644 index 00000000000..e96f3470b50 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTopicUtilTest.java @@ -0,0 +1,31 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.rest.server.messaging.BaseResourceMessage; +import org.hl7.fhir.r5.model.Enumeration; +import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SubscriptionTopicUtilTest { + @Test + public void testMatch() { + // I know this is gross. I haven't found a nicer way to do this + var create = new Enumeration<>(new SubscriptionTopic.InteractionTriggerEnumFactory()); + create.setValue(SubscriptionTopic.InteractionTrigger.CREATE); + var delete = new Enumeration<>(new SubscriptionTopic.InteractionTriggerEnumFactory()); + delete.setValue(SubscriptionTopic.InteractionTrigger.DELETE); + + List> supportedTypes = List.of(create, delete); + + assertTrue(SubscriptionTopicUtil.matches(BaseResourceMessage.OperationTypeEnum.CREATE, supportedTypes)); + assertFalse(SubscriptionTopicUtil.matches(BaseResourceMessage.OperationTypeEnum.UPDATE, supportedTypes)); + assertTrue(SubscriptionTopicUtil.matches(BaseResourceMessage.OperationTypeEnum.DELETE, supportedTypes)); + assertFalse(SubscriptionTopicUtil.matches(BaseResourceMessage.OperationTypeEnum.MANUALLY_TRIGGERED, supportedTypes)); + assertFalse(SubscriptionTopicUtil.matches(BaseResourceMessage.OperationTypeEnum.TRANSACTION, supportedTypes)); + } + +} diff --git a/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTriggerMatcherTest.java b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTriggerMatcherTest.java new file mode 100644 index 00000000000..a376a64c389 --- /dev/null +++ b/hapi-fhir-jpaserver-subscription/src/test/java/ca/uhn/fhir/jpa/topic/SubscriptionTriggerMatcherTest.java @@ -0,0 +1,133 @@ +package ca.uhn.fhir.jpa.topic; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult; +import ca.uhn.fhir.jpa.searchparam.matcher.SearchParamMatcher; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import org.hl7.fhir.r5.model.Encounter; +import org.hl7.fhir.r5.model.IdType; +import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SubscriptionTriggerMatcherTest { + private static final FhirContext ourFhirContext = FhirContext.forR5(); + @Mock + DaoRegistry myDaoRegistry; + @Mock + SearchParamMatcher mySearchParamMatcher; + + private SubscriptionTopicSupport mySubscriptionTopicSupport; + private Encounter myEncounter; + + @BeforeEach + public void before() { + mySubscriptionTopicSupport = new SubscriptionTopicSupport(ourFhirContext, myDaoRegistry, mySearchParamMatcher); + myEncounter = new Encounter(); + myEncounter.setIdElement(new IdType("Encounter", "123", "2")); + } + + @Test + public void testCreateEmptryTriggerNoMatch() { + // setup + ResourceModifiedMessage msg = new ResourceModifiedMessage(ourFhirContext, myEncounter, ResourceModifiedMessage.OperationTypeEnum.CREATE); + + SubscriptionTopic.SubscriptionTopicResourceTriggerComponent trigger = new SubscriptionTopic.SubscriptionTopicResourceTriggerComponent(); + + // run + SubscriptionTriggerMatcher svc = new SubscriptionTriggerMatcher(mySubscriptionTopicSupport, msg, trigger); + InMemoryMatchResult result = svc.match(); + + // verify + assertFalse(result.matched()); + } + + @Test + public void testCreateSimpleTriggerMatches() { + // setup + ResourceModifiedMessage msg = new ResourceModifiedMessage(ourFhirContext, myEncounter, ResourceModifiedMessage.OperationTypeEnum.CREATE); + + SubscriptionTopic.SubscriptionTopicResourceTriggerComponent trigger = new SubscriptionTopic.SubscriptionTopicResourceTriggerComponent(); + trigger.setResource("Encounter"); + trigger.addSupportedInteraction(SubscriptionTopic.InteractionTrigger.CREATE); + + // run + SubscriptionTriggerMatcher svc = new SubscriptionTriggerMatcher(mySubscriptionTopicSupport, msg, trigger); + InMemoryMatchResult result = svc.match(); + + // verify + assertTrue(result.matched()); + } + + @Test + public void testCreateWrongOpNoMatch() { + ResourceModifiedMessage msg = new ResourceModifiedMessage(ourFhirContext, myEncounter, ResourceModifiedMessage.OperationTypeEnum.CREATE); + + // setup + SubscriptionTopic.SubscriptionTopicResourceTriggerComponent trigger = new SubscriptionTopic.SubscriptionTopicResourceTriggerComponent(); + trigger.setResource("Encounter"); + trigger.addSupportedInteraction(SubscriptionTopic.InteractionTrigger.UPDATE); + + // run + SubscriptionTriggerMatcher svc = new SubscriptionTriggerMatcher(mySubscriptionTopicSupport, msg, trigger); + InMemoryMatchResult result = svc.match(); + + // verify + assertFalse(result.matched()); + } + + @Test + public void testUpdateMatch() { + ResourceModifiedMessage msg = new ResourceModifiedMessage(ourFhirContext, myEncounter, ResourceModifiedMessage.OperationTypeEnum.UPDATE); + + // setup + SubscriptionTopic.SubscriptionTopicResourceTriggerComponent trigger = new SubscriptionTopic.SubscriptionTopicResourceTriggerComponent(); + trigger.setResource("Encounter"); + trigger.addSupportedInteraction(SubscriptionTopic.InteractionTrigger.UPDATE); + + // run + SubscriptionTriggerMatcher svc = new SubscriptionTriggerMatcher(mySubscriptionTopicSupport, msg, trigger); + InMemoryMatchResult result = svc.match(); + + // verify + assertTrue(result.matched()); + } + + @Test + public void testUpdateWithPrevCriteriaMatch() { + ResourceModifiedMessage msg = new ResourceModifiedMessage(ourFhirContext, myEncounter, ResourceModifiedMessage.OperationTypeEnum.UPDATE); + + // setup + SubscriptionTopic.SubscriptionTopicResourceTriggerComponent trigger = new SubscriptionTopic.SubscriptionTopicResourceTriggerComponent(); + trigger.setResource("Encounter"); + trigger.addSupportedInteraction(SubscriptionTopic.InteractionTrigger.UPDATE); + trigger.getQueryCriteria().setPrevious("Encounter?status=in-progress"); + + + IFhirResourceDao mockEncounterDao = mock(IFhirResourceDao.class); + when(myDaoRegistry.getResourceDao("Encounter")).thenReturn(mockEncounterDao); + Encounter encounterPreviousVersion = new Encounter(); + when(mockEncounterDao.read(any(), any())).thenReturn(encounterPreviousVersion); + when(mySearchParamMatcher.match(any(), any(), any())).thenReturn(InMemoryMatchResult.successfulMatch()); + + // run + SubscriptionTriggerMatcher svc = new SubscriptionTriggerMatcher(mySubscriptionTopicSupport, msg, trigger); + InMemoryMatchResult result = svc.match(); + + // verify + assertTrue(result.matched()); + } + +} diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionValidatingInterceptorTest.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionValidatingInterceptorTest.java index f35a13824b8..81bd82a4959 100644 --- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionValidatingInterceptorTest.java +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionValidatingInterceptorTest.java @@ -7,9 +7,9 @@ import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; import ca.uhn.fhir.jpa.api.dao.DaoRegistry; import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc; import ca.uhn.fhir.jpa.subscription.match.matcher.matching.SubscriptionMatchingStrategy; -import ca.uhn.fhir.rest.api.server.SystemRequestDetails; import ca.uhn.fhir.jpa.subscription.match.matcher.matching.SubscriptionStrategyEvaluator; import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionCanonicalizer; +import ca.uhn.fhir.jpa.subscription.model.CanonicalSubscription; import ca.uhn.fhir.jpa.subscription.submit.interceptor.SubscriptionValidatingInterceptor; import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.api.server.RequestDetails; @@ -33,7 +33,7 @@ import static org.hamcrest.Matchers.containsString; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.nullable; @@ -299,6 +299,7 @@ public class SubscriptionValidatingInterceptorTest { @Test public void testSubscriptionUpdate() { + // setup when(myDaoRegistry.isResourceTypeSupported(eq("Patient"))).thenReturn(true); when(myStorageSettings.isCrossPartitionSubscriptionEnabled()).thenReturn(true); lenient() @@ -318,9 +319,11 @@ public class SubscriptionValidatingInterceptorTest { lenient() .when(requestDetails.getRestOperationType()).thenReturn(RestOperationTypeEnum.UPDATE); + // execute mySvc.resourceUpdated(subscription, subscription, requestDetails, null); - verify(mySubscriptionStrategyEvaluator).determineStrategy(anyString()); + // verify + verify(mySubscriptionStrategyEvaluator).determineStrategy(any(CanonicalSubscription.class)); verify(mySubscriptionCanonicalizer, times(2)).setMatchingStrategyTag(eq(subscription), nullable(SubscriptionMatchingStrategy.class)); } } diff --git a/hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/BaseSubscriptionsR4BTest.java b/hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/BaseSubscriptionsR4BTest.java new file mode 100644 index 00000000000..9dd1197fce1 --- /dev/null +++ b/hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/BaseSubscriptionsR4BTest.java @@ -0,0 +1,244 @@ +package ca.uhn.fhir.jpa.subscription; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; +import ca.uhn.fhir.jpa.provider.r4b.BaseResourceProviderR4BTest; +import ca.uhn.fhir.jpa.subscription.channel.impl.LinkedBlockingChannel; +import ca.uhn.fhir.jpa.subscription.submit.interceptor.SubscriptionMatcherInterceptor; +import ca.uhn.fhir.jpa.test.util.SubscriptionTestUtil; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.test.utilities.server.HashMapResourceProviderExtension; +import ca.uhn.fhir.test.utilities.server.RestfulServerExtension; +import ca.uhn.fhir.test.utilities.server.TransactionCapturingProviderExtension; +import ca.uhn.fhir.util.BundleUtil; +import com.apicatalog.jsonld.StringUtils; +import net.ttddyy.dsproxy.QueryCount; +import net.ttddyy.dsproxy.listener.SingleQueryCountHolder; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4b.model.Bundle; +import org.hl7.fhir.r4b.model.CodeableConcept; +import org.hl7.fhir.r4b.model.Coding; +import org.hl7.fhir.r4b.model.Enumerations; +import org.hl7.fhir.r4b.model.Extension; +import org.hl7.fhir.r4b.model.Observation; +import org.hl7.fhir.r4b.model.Organization; +import org.hl7.fhir.r4b.model.Patient; +import org.hl7.fhir.r4b.model.Subscription; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; + +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public abstract class BaseSubscriptionsR4BTest extends BaseResourceProviderR4BTest { + private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BaseSubscriptionsR4BTest.class); + protected static int ourListenerPort; + + @Order(0) + @RegisterExtension + protected static RestfulServerExtension ourRestfulServer = new RestfulServerExtension(FhirContext.forR4BCached()); + @Order(1) + @RegisterExtension + protected static HashMapResourceProviderExtension ourPatientProvider = new HashMapResourceProviderExtension<>(ourRestfulServer, Patient.class); + @Order(1) + @RegisterExtension + protected static HashMapResourceProviderExtension ourObservationProvider = new HashMapResourceProviderExtension<>(ourRestfulServer, Observation.class); + @Order(1) + @RegisterExtension + protected static TransactionCapturingProviderExtension ourTransactionProvider = new TransactionCapturingProviderExtension<>(ourRestfulServer, Bundle.class); + protected static SingleQueryCountHolder ourCountHolder; + @Order(1) + @RegisterExtension + protected static HashMapResourceProviderExtension ourOrganizationProvider = new HashMapResourceProviderExtension<>(ourRestfulServer, Organization.class); + @Autowired + protected SubscriptionTestUtil mySubscriptionTestUtil; + @Autowired + protected SubscriptionMatcherInterceptor mySubscriptionMatcherInterceptor; + protected CountingInterceptor myCountingInterceptor; + protected List mySubscriptionIds = Collections.synchronizedList(new ArrayList<>()); + @Autowired + private SingleQueryCountHolder myCountHolder; + + @AfterEach + public void afterUnregisterRestHookListener() { + for (IIdType next : mySubscriptionIds) { + IIdType nextId = next.toUnqualifiedVersionless(); + ourLog.info("Deleting: {}", nextId); + myClient.delete().resourceById(nextId).execute(); + } + mySubscriptionIds.clear(); + + myStorageSettings.setAllowMultipleDelete(true); + ourLog.info("Deleting all subscriptions"); + myClient.delete().resourceConditionalByUrl("Subscription?status=active").execute(); + myClient.delete().resourceConditionalByUrl("Observation?code:missing=false").execute(); + ourLog.info("Done deleting all subscriptions"); + myStorageSettings.setAllowMultipleDelete(new JpaStorageSettings().isAllowMultipleDelete()); + + mySubscriptionTestUtil.unregisterSubscriptionInterceptor(); + } + + @BeforeEach + public void beforeRegisterRestHookListener() { + mySubscriptionTestUtil.registerRestHookInterceptor(); + } + + @BeforeEach + public void beforeReset() throws Exception { + // Delete all Subscriptions + if (myClient != null) { + Bundle allSubscriptions = myClient.search().forResource(Subscription.class).returnBundle(Bundle.class).execute(); + for (IBaseResource next : BundleUtil.toListOfResources(myFhirContext, allSubscriptions)) { + myClient.delete().resource(next).execute(); + } + waitForActivatedSubscriptionCount(0); + } + + LinkedBlockingChannel processingChannel = mySubscriptionMatcherInterceptor.getProcessingChannelForUnitTest(); + if (processingChannel != null) { + processingChannel.clearInterceptorsForUnitTest(); + } + myCountingInterceptor = new CountingInterceptor(); + if (processingChannel != null) { + processingChannel.addInterceptor(myCountingInterceptor); + } + } + + + protected Subscription createSubscription(String theCriteria, String thePayload) { + return createSubscription(theCriteria, thePayload, null); + } + + protected Subscription createSubscription(String theCriteria, String thePayload, Extension theExtension) { + String id = null; + + return createSubscription(theCriteria, thePayload, theExtension, id); + } + + @NotNull + protected Subscription createSubscription(String theCriteria, String thePayload, Extension theExtension, String id) { + Subscription subscription = newSubscription(theCriteria, thePayload); + if (theExtension != null) { + subscription.getChannel().addExtension(theExtension); + } + if (id != null) { + subscription.setId(id); + } + + subscription = postOrPutSubscription(subscription); + return subscription; + } + + protected Subscription postOrPutSubscription(IBaseResource theSubscription) { + MethodOutcome methodOutcome; + if (theSubscription.getIdElement().isEmpty()) { + methodOutcome = myClient.create().resource(theSubscription).execute(); + } else { + methodOutcome = myClient.update().resource(theSubscription).execute(); + } + theSubscription.setId(methodOutcome.getId().toUnqualifiedVersionless()); + mySubscriptionIds.add(methodOutcome.getId()); + return (Subscription) theSubscription; + } + + protected Subscription newSubscription(String theCriteria, String thePayload) { + return newSubscriptionWithStatus(theCriteria, thePayload, Enumerations.SubscriptionStatus.ACTIVE); + } + + @Nonnull + protected Subscription newSubscriptionWithStatus(String theCriteria, String thePayload, Enumerations.SubscriptionStatus theSubscriptionStatus) { + Subscription subscription = new Subscription(); + subscription.setReason("Monitor new neonatal function (note, age will be determined by the monitor)"); + subscription.setStatus(theSubscriptionStatus); + subscription.setCriteria(theCriteria); + + Subscription.SubscriptionChannelComponent channel = subscription.getChannel(); + channel.setType(Subscription.SubscriptionChannelType.RESTHOOK); + channel.setPayload(thePayload); + channel.setEndpoint(ourRestfulServer.getBaseUrl()); + return subscription; + } + + + protected void waitForQueueToDrain() throws InterruptedException { + mySubscriptionTestUtil.waitForQueueToDrain(); + } + + @PostConstruct + public void initializeOurCountHolder() { + ourCountHolder = myCountHolder; + } + + + protected Observation sendObservation(String theCode, String theSystem) { + return sendObservation(theCode, theSystem, null, null); + } + + protected Observation sendObservation(String theCode, String theSystem, String theSource, String theRequestId) { + Observation observation = createBaseObservation(theCode, theSystem); + if (StringUtils.isNotBlank(theSource)) { + observation.getMeta().setSource(theSource); + } + + SystemRequestDetails systemRequestDetails = new SystemRequestDetails(); + if (StringUtils.isNotBlank(theRequestId)) { + systemRequestDetails.setRequestId(theRequestId); + } + IIdType id = myObservationDao.create(observation, systemRequestDetails).getId(); + observation.setId(id); + return observation; + } + + protected Observation createBaseObservation(String theCode, String theSystem) { + Observation observation = new Observation(); + CodeableConcept codeableConcept = new CodeableConcept(); + observation.setCode(codeableConcept); + observation.getIdentifierFirstRep().setSystem("foo").setValue("1"); + Coding coding = codeableConcept.addCoding(); + coding.setCode(theCode); + coding.setSystem(theSystem); + + observation.setStatus(Enumerations.ObservationStatus.FINAL); + return observation; + } + + protected Patient sendPatient() { + Patient patient = new Patient(); + patient.setActive(true); + + IIdType id = myPatientDao.create(patient).getId(); + patient.setId(id); + + return patient; + } + + protected Organization sendOrganization() { + Organization org = new Organization(); + org.setName("ORG"); + + IIdType id = myOrganizationDao.create(org).getId(); + org.setId(id); + + return org; + } + + @AfterAll + public static void reportTotalSelects() { + ourLog.info("Total database select queries: {}", getQueryCount().getSelect()); + } + + private static QueryCount getQueryCount() { + return ourCountHolder.getQueryCountMap().get(""); + } + +} diff --git a/hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/RestHookTestR4BTest.java b/hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/RestHookTestR4BTest.java new file mode 100644 index 00000000000..ce511e8fa5d --- /dev/null +++ b/hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/RestHookTestR4BTest.java @@ -0,0 +1,1285 @@ +package ca.uhn.fhir.jpa.subscription; + +import ca.uhn.fhir.i18n.Msg; +import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; +import ca.uhn.fhir.jpa.test.util.StoppableSubscriptionDeliveringRestHookSubscriber; +import ca.uhn.fhir.model.primitive.IdDt; +import ca.uhn.fhir.rest.api.CacheControlDirective; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.MethodOutcome; +import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; +import ca.uhn.fhir.util.HapiExtensions; +import org.hl7.fhir.instance.model.api.IBaseBundle; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4b.model.BooleanType; +import org.hl7.fhir.r4b.model.Bundle; +import org.hl7.fhir.r4b.model.CodeableConcept; +import org.hl7.fhir.r4b.model.Coding; +import org.hl7.fhir.r4b.model.Enumerations; +import org.hl7.fhir.r4b.model.Extension; +import org.hl7.fhir.r4b.model.IdType; +import org.hl7.fhir.r4b.model.Meta; +import org.hl7.fhir.r4b.model.Observation; +import org.hl7.fhir.r4b.model.Patient; +import org.hl7.fhir.r4b.model.SearchParameter; +import org.hl7.fhir.r4b.model.StringType; +import org.hl7.fhir.r4b.model.Subscription; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static ca.uhn.fhir.util.HapiExtensions.EX_SEND_DELETE_MESSAGES; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.matchesPattern; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Test the rest-hook subscriptions + */ +public class RestHookTestR4BTest extends BaseSubscriptionsR4BTest { + private static final Logger ourLog = LoggerFactory.getLogger(RestHookTestR4BTest.class); + + @Autowired + StoppableSubscriptionDeliveringRestHookSubscriber myStoppableSubscriptionDeliveringRestHookSubscriber; + + @AfterEach + public void cleanupStoppableSubscriptionDeliveringRestHookSubscriber() { + ourLog.info("@AfterEach"); + myStoppableSubscriptionDeliveringRestHookSubscriber.setCountDownLatch(null); + myStoppableSubscriptionDeliveringRestHookSubscriber.unPause(); + myStorageSettings.setTriggerSubscriptionsForNonVersioningChanges(new JpaStorageSettings().isTriggerSubscriptionsForNonVersioningChanges()); + } + + + + /** + * Make sure that if we delete a subscription, then reinstate it with a criteria + * that changes the database mode we don't store both versioning modes + */ + @Test + public void testReuseSubscriptionIdWithDifferentDatabaseMode() throws Exception { + myStorageSettings.setTagStorageMode(JpaStorageSettings.TagStorageModeEnum.NON_VERSIONED); + + String payload = "application/fhir+json"; + IdType id = createSubscription("Observation?_has:DiagnosticReport:result:identifier=foo|IDENTIFIER", payload, null, "sub").getIdElement().toUnqualifiedVersionless(); + waitForActivatedSubscriptionCount(1); + + Subscription subscription = mySubscriptionDao.read(id, mySrd); + assertEquals(1, subscription.getMeta().getTag().size()); + assertEquals("DATABASE", subscription.getMeta().getTag().get(0).getCode()); + + mySubscriptionDao.delete(id, mySrd); + waitForActivatedSubscriptionCount(0); + + payload = "application/fhir+json"; + id = createSubscription("Observation?", payload, null, "sub").getIdElement().toUnqualifiedVersionless(); + waitForActivatedSubscriptionCount(1); + + subscription = mySubscriptionDao.read(id, mySrd); + assertEquals(1, subscription.getMeta().getTag().size()); + assertEquals("IN_MEMORY", subscription.getMeta().getTag().get(0).getCode()); + } + + + @Test + public void testRestHookSubscriptionApplicationFhirJson() throws Exception { + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=json"; + String criteria2 = "Observation?code=SNOMED-CT|" + code + "111&_format=json"; + + createSubscription(criteria1, Constants.CT_FHIR_JSON_NEW); + createSubscription(criteria2, Constants.CT_FHIR_JSON_NEW); + waitForActivatedSubscriptionCount(2); + + sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + } + + @Test + public void testUpdatesHaveCorrectMetadata() throws Exception { + String payload = "application/fhir+json"; + + String code = "1000000050"; + String criteria1 = "Observation?"; + + createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + /* + * Send version 1 + */ + + Observation obs = sendObservation(code, "SNOMED-CT", "http://source-system.com", null); + obs = myObservationDao.read(obs.getIdElement().toUnqualifiedVersionless()); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + assertEquals("1", ourObservationProvider.getStoredResources().get(0).getIdElement().getVersionIdPart()); + assertEquals("1", ourObservationProvider.getStoredResources().get(0).getMeta().getVersionId()); + assertEquals("http://source-system.com", ourObservationProvider.getStoredResources().get(0).getMeta().getSource()); + assertEquals(obs.getMeta().getLastUpdatedElement().getValueAsString(), ourObservationProvider.getStoredResources().get(0).getMeta().getLastUpdatedElement().getValueAsString()); + assertEquals(obs.getMeta().getLastUpdatedElement().getValueAsString(), ourObservationProvider.getStoredResources().get(0).getMeta().getLastUpdatedElement().getValueAsString()); + assertEquals("1", ourObservationProvider.getStoredResources().get(0).getIdentifierFirstRep().getValue()); + + /* + * Send version 2 + */ + + obs.getIdentifierFirstRep().setSystem("foo").setValue("2"); + obs.getMeta().setSource("http://other-source"); + myObservationDao.update(obs); + obs = myObservationDao.read(obs.getIdElement().toUnqualifiedVersionless()); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(2); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + assertEquals("2", ourObservationProvider.getStoredResources().get(0).getIdElement().getVersionIdPart()); + assertEquals("2", ourObservationProvider.getStoredResources().get(0).getMeta().getVersionId()); + assertEquals("http://other-source", ourObservationProvider.getStoredResources().get(0).getMeta().getSource()); + assertEquals(obs.getMeta().getLastUpdatedElement().getValueAsString(), ourObservationProvider.getStoredResources().get(0).getMeta().getLastUpdatedElement().getValueAsString()); + assertEquals(obs.getMeta().getLastUpdatedElement().getValueAsString(), ourObservationProvider.getStoredResources().get(0).getMeta().getLastUpdatedElement().getValueAsString()); + assertEquals("2", ourObservationProvider.getStoredResources().get(0).getIdentifierFirstRep().getValue()); + } + + @Test + public void testPlaceholderReferencesInTransactionAreResolvedCorrectly() throws Exception { + + String payload = "application/fhir+json"; + String code = "1000000050"; + String criteria1 = "Observation?"; + createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + // Create a transaction that should match + Bundle bundle = new Bundle(); + bundle.setType(Bundle.BundleType.TRANSACTION); + + Patient patient = new Patient(); + patient.setId(IdType.newRandomUuid()); + patient.getIdentifierFirstRep().setSystem("foo").setValue("AAA"); + bundle.addEntry().setResource(patient).getRequest().setMethod(Bundle.HTTPVerb.POST).setUrl("Patient"); + + Observation observation = new Observation(); + observation.getIdentifierFirstRep().setSystem("foo").setValue("1"); + observation.getCode().addCoding().setCode(code).setSystem("SNOMED-CT"); + observation.setStatus(Enumerations.ObservationStatus.FINAL); + observation.getSubject().setReference(patient.getId()); + bundle.addEntry().setResource(observation).getRequest().setMethod(Bundle.HTTPVerb.POST).setUrl("Observation"); + + // Send the transaction + mySystemDao.transaction(null, bundle); + + ourObservationProvider.waitForUpdateCount(1); + + assertThat(ourObservationProvider.getStoredResources().get(0).getSubject().getReference(), matchesPattern("Patient/[0-9]+")); + } + + @Test + public void testUpdatesHaveCorrectMetadataUsingTransactions() throws Exception { + String payload = "application/fhir+json"; + + String code = "1000000050"; + String criteria1 = "Observation?"; + + createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + /* + * Send version 1 + */ + + Observation observation = new Observation(); + observation.getIdentifierFirstRep().setSystem("foo").setValue("1"); + observation.getCode().addCoding().setCode(code).setSystem("SNOMED-CT"); + observation.setStatus(Enumerations.ObservationStatus.FINAL); + Bundle bundle = new Bundle(); + bundle.setType(Bundle.BundleType.TRANSACTION); + bundle.addEntry().setResource(observation).getRequest().setMethod(Bundle.HTTPVerb.POST).setUrl("Observation"); + Bundle responseBundle = mySystemDao.transaction(null, bundle); + + Observation obs = myObservationDao.read(new IdType(responseBundle.getEntry().get(0).getResponse().getLocation())); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + assertEquals("1", ourObservationProvider.getStoredResources().get(0).getIdElement().getVersionIdPart()); + assertEquals("1", ourObservationProvider.getStoredResources().get(0).getMeta().getVersionId()); + assertEquals(obs.getMeta().getLastUpdatedElement().getValueAsString(), ourObservationProvider.getStoredResources().get(0).getMeta().getLastUpdatedElement().getValueAsString()); + assertEquals("1", ourObservationProvider.getStoredResources().get(0).getIdentifierFirstRep().getValue()); + + /* + * Send version 2 + */ + + observation = new Observation(); + observation.setId(obs.getId()); + observation.getIdentifierFirstRep().setSystem("foo").setValue("2"); + observation.getCode().addCoding().setCode(code).setSystem("SNOMED-CT"); + observation.setStatus(Enumerations.ObservationStatus.FINAL); + bundle = new Bundle(); + bundle.setType(Bundle.BundleType.TRANSACTION); + bundle.addEntry().setResource(observation).getRequest().setMethod(Bundle.HTTPVerb.PUT).setUrl(obs.getIdElement().toUnqualifiedVersionless().getValue()); + mySystemDao.transaction(null, bundle); + obs = myObservationDao.read(obs.getIdElement().toUnqualifiedVersionless()); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(2); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + assertEquals("2", ourObservationProvider.getStoredResources().get(0).getIdElement().getVersionIdPart()); + assertEquals("2", ourObservationProvider.getStoredResources().get(0).getMeta().getVersionId()); + assertEquals(obs.getMeta().getLastUpdatedElement().getValueAsString(), ourObservationProvider.getStoredResources().get(0).getMeta().getLastUpdatedElement().getValueAsString()); + assertEquals("2", ourObservationProvider.getStoredResources().get(0).getIdentifierFirstRep().getValue()); + } + + @Test + public void testRepeatedDeliveries() throws Exception { + String payload = "application/fhir+json"; + + String code = "1000000050"; + String criteria1 = "Observation?"; + + createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + for (int i = 0; i < 100; i++) { + Observation observation = new Observation(); + observation.getIdentifierFirstRep().setSystem("foo").setValue("ID" + i); + observation.getCode().addCoding().setCode(code).setSystem("SNOMED-CT"); + observation.setStatus(Enumerations.ObservationStatus.FINAL); + myObservationDao.create(observation); + } + + ourObservationProvider.waitForUpdateCount(100); + } + + + @Test + public void testSubscriptionRegistryLoadsSubscriptionsFromDatabase() throws Exception { + String payload = "application/fhir+json"; + + String code = "1000000050"; + String criteria1 = "Observation?"; + + createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + // Manually unregister all subscriptions + mySubscriptionRegistry.unregisterAllSubscriptions(); + assertEquals(0, mySubscriptionRegistry.size()); + + // Force a reload + mySubscriptionLoader.doSyncSubscriptionsForUnitTest(); + + // Send a matching observation + Observation observation = new Observation(); + observation.getIdentifierFirstRep().setSystem("foo").setValue("ID"); + observation.getCode().addCoding().setCode(code).setSystem("SNOMED-CT"); + observation.setStatus(Enumerations.ObservationStatus.FINAL); + myObservationDao.create(observation); + + ourObservationProvider.waitForUpdateCount(1); + } + + @Test + public void testActiveSubscriptionShouldntReActivate() throws Exception { + String criteria = "Observation?code=111111111&_format=xml"; + String payload = "application/fhir+json"; + createSubscription(criteria, payload); + + waitForActivatedSubscriptionCount(1); + for (int i = 0; i < 5; i++) { + int changes = this.mySubscriptionLoader.doSyncSubscriptionsForUnitTest(); + assertEquals(0, changes); + } + } + + @Test + public void testRestHookSubscriptionMetaAddDoesntTriggerNewDelivery() throws Exception { + String payload = "application/fhir+json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + String criteria2 = "Observation?code=SNOMED-CT|" + code + "111&_format=xml"; + + createSubscription(criteria1, payload); + createSubscription(criteria2, payload); + waitForActivatedSubscriptionCount(2); + + ourLog.info("Sending an Observation"); + Observation obs = sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + + // Send a meta-add + ourLog.info("Sending a meta-add"); + obs.setId(obs.getIdElement().toUnqualifiedVersionless()); + myClient.meta().add().onResource(obs.getIdElement()).meta(new Meta().addTag("http://blah", "blah", null)).execute(); + + obs = myClient.read().resource(Observation.class).withId(obs.getIdElement().toUnqualifiedVersionless()).execute(); + Coding tag = obs.getMeta().getTag("http://blah", "blah"); + assertNotNull(tag); + + // Should be no further deliveries + Thread.sleep(1000); + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + + // Send a meta-delete + obs.setId(obs.getIdElement().toUnqualifiedVersionless()); + myClient.meta().delete().onResource(obs.getIdElement()).meta(new Meta().addTag("http://blah", "blah", null)).execute(); + + obs = myClient.read().resource(Observation.class).withId(obs.getIdElement().toUnqualifiedVersionless()).execute(); + tag = obs.getMeta().getTag("http://blah", "blah"); + assertNull(tag); + + // Should be no further deliveries + Thread.sleep(1000); + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + + } + + @Test + public void testRestHookSubscriptionMetaAddDoesTriggerNewDeliveryIfConfiguredToDoSo() throws Exception { + myStorageSettings.setTriggerSubscriptionsForNonVersioningChanges(true); + + String payload = "application/fhir+json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + String criteria2 = "Observation?code=SNOMED-CT|" + code + "111&_format=xml"; + + createSubscription(criteria1, payload); + createSubscription(criteria2, payload); + waitForActivatedSubscriptionCount(2); + + Observation obs = sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + + // Send a meta-add + obs.setId(obs.getIdElement().toUnqualifiedVersionless()); + myClient.meta().add().onResource(obs.getIdElement()).meta(new Meta().addTag("http://blah", "blah", null)).execute(); + + obs = myClient.read().resource(Observation.class).withId(obs.getIdElement().toUnqualifiedVersionless()).execute(); + Coding tag = obs.getMeta().getTag("http://blah", "blah"); + assertNotNull(tag); + + // Should be no further deliveries + Thread.sleep(1000); + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(3); + + // Send a meta-delete + obs.setId(obs.getIdElement().toUnqualifiedVersionless()); + myClient.meta().delete().onResource(obs.getIdElement()).meta(new Meta().addTag("http://blah", "blah", null)).execute(); + + obs = myClient.read().resource(Observation.class).withId(obs.getIdElement().toUnqualifiedVersionless()).execute(); + tag = obs.getMeta().getTag("http://blah", "blah"); + assertNull(tag); + + // Should be no further deliveries + Thread.sleep(1000); + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(5); + + } + + @Test + public void testRestHookSubscriptionNoopUpdateDoesntTriggerNewDelivery() throws Exception { + String payload = "application/fhir+json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + String criteria2 = "Observation?code=SNOMED-CT|" + code + "111&_format=xml"; + + createSubscription(criteria1, payload); + createSubscription(criteria2, payload); + waitForActivatedSubscriptionCount(2); + + Observation obs = sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + + // Send an update with no changes + obs.setId(obs.getIdElement().toUnqualifiedVersionless()); + myClient.update().resource(obs).execute(); + + // Should be no further deliveries + Thread.sleep(1000); + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + + + } + + @Test + public void testRestHookSubscriptionApplicationJsonDisableVersionIdInDelivery() throws Exception { + String payload = "application/json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + + waitForActivatedSubscriptionCount(0); + Subscription subscription1 = createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + + ourLog.info("** About to send observation"); + Observation observation1 = sendObservation(code, "SNOMED-CT"); + + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + + IdType idElement = ourObservationProvider.getStoredResources().get(0).getIdElement(); + assertEquals(observation1.getIdElement().getIdPart(), idElement.getIdPart()); + // VersionId is present + assertEquals(observation1.getIdElement().getVersionIdPart(), idElement.getVersionIdPart()); + + subscription1 + .getChannel() + .addExtension(HapiExtensions.EXT_SUBSCRIPTION_RESTHOOK_STRIP_VERSION_IDS, new BooleanType("true")); + ourLog.info("** About to update subscription"); + + int modCount = myCountingInterceptor.getSentCount("Subscription"); + myClient.update().resource(subscription1).execute(); + waitForSize(modCount + 1, () -> myCountingInterceptor.getSentCount("Subscription"), () -> myCountingInterceptor.toString()); + + ourLog.info("** About to send observation"); + Observation observation2 = sendObservation(code, "SNOMED-CT"); + + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(2); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(1)); + + idElement = ourObservationProvider.getResourceUpdates().get(1).getIdElement(); + assertEquals(observation2.getIdElement().getIdPart(), idElement.getIdPart()); + // Now VersionId is stripped + assertEquals(null, idElement.getVersionIdPart()); + } + + @Test + public void testRestHookSubscriptionDoesntGetLatestVersionByDefault() throws Exception { + String payload = "application/json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + + waitForActivatedSubscriptionCount(0); + createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + myStoppableSubscriptionDeliveringRestHookSubscriber.pause(); + final CountDownLatch countDownLatch = new CountDownLatch(1); + myStoppableSubscriptionDeliveringRestHookSubscriber.setCountDownLatch(countDownLatch); + + ourLog.info("** About to send observation"); + Observation observation = sendObservation(code, "SNOMED-CT"); + assertEquals("1", observation.getIdElement().getVersionIdPart()); + assertNull(observation.getNoteFirstRep().getText()); + + observation.getNoteFirstRep().setText("changed"); + MethodOutcome methodOutcome = myClient.update().resource(observation).execute(); + assertEquals("2", methodOutcome.getId().getVersionIdPart()); + assertEquals("changed", observation.getNoteFirstRep().getText()); + + // Wait for our two delivery channel threads to be paused + assertTrue(countDownLatch.await(5L, TimeUnit.SECONDS)); + // Open the floodgates! + myStoppableSubscriptionDeliveringRestHookSubscriber.unPause(); + + + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(2); + + Observation observation1 = ourObservationProvider.getResourceUpdates().stream().filter(t->t.getIdElement().getVersionIdPart().equals("1")).findFirst().orElseThrow(()->new IllegalArgumentException()); + Observation observation2 = ourObservationProvider.getResourceUpdates().stream().filter(t->t.getIdElement().getVersionIdPart().equals("2")).findFirst().orElseThrow(()->new IllegalArgumentException()); + + assertEquals("1", observation1.getIdElement().getVersionIdPart()); + assertNull(observation1.getNoteFirstRep().getText()); + assertEquals("2", observation2.getIdElement().getVersionIdPart()); + assertEquals("changed", observation2.getNoteFirstRep().getText()); + } + + + @ParameterizedTest + @ValueSource(strings = {"[*]", "[Observation]", "Observation?"}) + public void RestHookSubscriptionWithPayloadSendsDeleteRequest(String theCriteria) throws Exception { + String payload = "application/json"; + + Extension sendDeleteMessagesExtension = new Extension() + .setUrl(EX_SEND_DELETE_MESSAGES) + .setValue(new BooleanType(true)); + + waitForActivatedSubscriptionCount(0); + createSubscription(theCriteria, payload, sendDeleteMessagesExtension); + waitForActivatedSubscriptionCount(1); + + ourLog.info("** About to send observation"); + Observation observation = sendObservation("OB-01", "SNOMED-CT"); + assertEquals("1", observation.getIdElement().getVersionIdPart()); + ourObservationProvider.waitForUpdateCount(1); + + ourLog.info("** About to delete observation"); + myObservationDao.delete(IdDt.of(observation).toUnqualifiedVersionless()); + ourObservationProvider.waitForDeleteCount(1); + } + + + @Test + public void testRestHookSubscriptionGetsLatestVersionWithFlag() throws Exception { + String payload = "application/json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + + waitForActivatedSubscriptionCount(0); + + Subscription subscription = newSubscription(criteria1, payload); + subscription + .getChannel() + .addExtension(HapiExtensions.EXT_SUBSCRIPTION_RESTHOOK_DELIVER_LATEST_VERSION, new BooleanType("true")); + myClient.create().resource(subscription).execute(); + + waitForActivatedSubscriptionCount(1); + + myStoppableSubscriptionDeliveringRestHookSubscriber.pause(); + final CountDownLatch countDownLatch = new CountDownLatch(1); + myStoppableSubscriptionDeliveringRestHookSubscriber.setCountDownLatch(countDownLatch); + + ourLog.info("** About to send observation"); + Observation observation = sendObservation(code, "SNOMED-CT"); + assertEquals("1", observation.getIdElement().getVersionIdPart()); + assertNull(observation.getNoteFirstRep().getText()); + + observation.getNoteFirstRep().setText("changed"); + MethodOutcome methodOutcome = myClient.update().resource(observation).execute(); + assertEquals("2", methodOutcome.getId().getVersionIdPart()); + assertEquals("changed", observation.getNoteFirstRep().getText()); + + // Wait for our two delivery channel threads to be paused + assertTrue(countDownLatch.await(5L, TimeUnit.SECONDS)); + // Open the floodgates! + myStoppableSubscriptionDeliveringRestHookSubscriber.unPause(); + + + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(2); + + Observation observation1 = ourObservationProvider.getResourceUpdates().get(0); + Observation observation2 = ourObservationProvider.getResourceUpdates().get(1); + + assertEquals("2", observation1.getIdElement().getVersionIdPart()); + assertEquals("changed", observation1.getNoteFirstRep().getText()); + assertEquals("2", observation2.getIdElement().getVersionIdPart()); + assertEquals("changed", observation2.getNoteFirstRep().getText()); + } + + @Test + public void testRestHookSubscriptionApplicationJson() throws Exception { + String payload = "application/json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + String criteria2 = "Observation?code=SNOMED-CT|" + code + "111&_format=xml"; + + Subscription subscription1 = createSubscription(criteria1, payload); + Subscription subscription2 = createSubscription(criteria2, payload); + waitForActivatedSubscriptionCount(2); + + Observation observation1 = sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + + assertEquals("1", ourObservationProvider.getStoredResources().get(0).getIdElement().getVersionIdPart()); + + Subscription subscriptionTemp = myClient.read(Subscription.class, subscription2.getId()); + assertNotNull(subscriptionTemp); + + subscriptionTemp.setCriteria(criteria1); + myClient.update().resource(subscriptionTemp).withId(subscriptionTemp.getIdElement()).execute(); + waitForQueueToDrain(); + + Observation observation2 = sendObservation(code, "SNOMED-CT"); + waitForQueueToDrain(); + + // Should see two subscription notifications + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(3); + + myClient.delete().resourceById(new IdType("Subscription/" + subscription2.getId())).execute(); + waitForActivatedSubscriptionCount(1); + + Observation observationTemp3 = sendObservation(code, "SNOMED-CT"); + waitForQueueToDrain(); + + // Should see only one subscription notification + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(4); + + Observation observation3 = myClient.read(Observation.class, observationTemp3.getId()); + CodeableConcept codeableConcept = new CodeableConcept(); + observation3.setCode(codeableConcept); + Coding coding = codeableConcept.addCoding(); + coding.setCode(code + "111"); + coding.setSystem("SNOMED-CT"); + myClient.update().resource(observation3).withId(observation3.getIdElement()).execute(); + + // Should see no subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(4); + + Observation observation3a = myClient.read(Observation.class, observationTemp3.getId()); + + CodeableConcept codeableConcept1 = new CodeableConcept(); + observation3a.setCode(codeableConcept1); + Coding coding1 = codeableConcept1.addCoding(); + coding1.setCode(code); + coding1.setSystem("SNOMED-CT"); + myClient.update().resource(observation3a).withId(observation3a.getIdElement()).execute(); + + // Should see only one subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(5); + + assertFalse(subscription1.getId().equals(subscription2.getId())); + assertFalse(observation1.getId().isEmpty()); + assertFalse(observation2.getId().isEmpty()); + } + + @Test + public void testRestHookSubscriptionApplicationJsonDatabase() throws Exception { + // Same test as above, but now run it using database matching + myStorageSettings.setEnableInMemorySubscriptionMatching(false); + String payload = "application/json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + String criteria2 = "Observation?code=SNOMED-CT|" + code + "111&_format=xml"; + + Subscription subscription1 = createSubscription(criteria1, payload); + Subscription subscription2 = createSubscription(criteria2, payload); + waitForActivatedSubscriptionCount(2); + + Observation observation1 = sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + + assertEquals("1", ourObservationProvider.getStoredResources().get(0).getIdElement().getVersionIdPart()); + + Subscription subscriptionTemp = myClient.read(Subscription.class, subscription2.getId()); + assertNotNull(subscriptionTemp); + + subscriptionTemp.setCriteria(criteria1); + myClient.update().resource(subscriptionTemp).withId(subscriptionTemp.getIdElement()).execute(); + waitForQueueToDrain(); + + Observation observation2 = sendObservation(code, "SNOMED-CT"); + waitForQueueToDrain(); + + // Should see two subscription notifications + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(3); + + myClient.delete().resourceById(new IdType("Subscription/" + subscription2.getId())).execute(); + waitForQueueToDrain(); + + Observation observationTemp3 = sendObservation(code, "SNOMED-CT"); + waitForQueueToDrain(); + + // Should see only one subscription notification + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(4); + + Observation observation3 = myClient.read(Observation.class, observationTemp3.getId()); + CodeableConcept codeableConcept = new CodeableConcept(); + observation3.setCode(codeableConcept); + Coding coding = codeableConcept.addCoding(); + coding.setCode(code + "111"); + coding.setSystem("SNOMED-CT"); + myClient.update().resource(observation3).withId(observation3.getIdElement()).execute(); + + // Should see no subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(4); + + Observation observation3a = myClient.read(Observation.class, observationTemp3.getId()); + + CodeableConcept codeableConcept1 = new CodeableConcept(); + observation3a.setCode(codeableConcept1); + Coding coding1 = codeableConcept1.addCoding(); + coding1.setCode(code); + coding1.setSystem("SNOMED-CT"); + myClient.update().resource(observation3a).withId(observation3a.getIdElement()).execute(); + + // Should see only one subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(5); + + assertFalse(subscription1.getId().equals(subscription2.getId())); + assertFalse(observation1.getId().isEmpty()); + assertFalse(observation2.getId().isEmpty()); + } + + @Test + public void testRestHookSubscriptionApplicationXml() throws Exception { + String payload = "application/xml"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + String criteria2 = "Observation?code=SNOMED-CT|" + code + "111&_format=xml"; + + Subscription subscription1 = createSubscription(criteria1, payload); + Subscription subscription2 = createSubscription(criteria2, payload); + waitForActivatedSubscriptionCount(2); + + ourLog.info("** About to send observation"); + Observation observation1 = sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_XML_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + + Subscription subscriptionTemp = myClient.read(Subscription.class, subscription2.getId()); + assertNotNull(subscriptionTemp); + subscriptionTemp.setCriteria(criteria1); + myClient.update().resource(subscriptionTemp).withId(subscriptionTemp.getIdElement()).execute(); + waitForQueueToDrain(); + + Observation observation2 = sendObservation(code, "SNOMED-CT"); + waitForQueueToDrain(); + + // Should see two subscription notifications + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(3); + + myClient.delete().resourceById(new IdType("Subscription/" + subscription2.getId())).execute(); + + Observation observationTemp3 = sendObservation(code, "SNOMED-CT"); + + // Should see only one subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(4); + + Observation observation3 = myClient.read(Observation.class, observationTemp3.getId()); + CodeableConcept codeableConcept = new CodeableConcept(); + observation3.setCode(codeableConcept); + Coding coding = codeableConcept.addCoding(); + coding.setCode(code + "111"); + coding.setSystem("SNOMED-CT"); + myClient.update().resource(observation3).withId(observation3.getIdElement()).execute(); + + // Should see no subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(4); + + Observation observation3a = myClient.read(Observation.class, observationTemp3.getId()); + + CodeableConcept codeableConcept1 = new CodeableConcept(); + observation3a.setCode(codeableConcept1); + Coding coding1 = codeableConcept1.addCoding(); + coding1.setCode(code); + coding1.setSystem("SNOMED-CT"); + myClient.update().resource(observation3a).withId(observation3a.getIdElement()).execute(); + + // Should see only one subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(5); + + assertFalse(subscription1.getId().equals(subscription2.getId())); + assertFalse(observation1.getId().isEmpty()); + assertFalse(observation2.getId().isEmpty()); + } + + @Test + public void testRestHookSubscriptionStarCriteria() throws Exception { + String payload = "application/json"; + + String code = "1000000050"; + String criteria1 = "[*]"; + + createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + sendObservation(code, "SNOMED-CT"); + sendPatient(); + + waitForQueueToDrain(); + + // Should see 1 subscription notification for each type + ourObservationProvider.waitForCreateCount(0); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + ourPatientProvider.waitForCreateCount(0); + ourPatientProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(1)); + + } + + + @Test + public void testRestHookSubscriptionMultiTypeCriteria() throws Exception { + String payload = "application/json"; + + String code = "1000000050"; + String criteria1 = "[Observation,Patient]"; + + createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + sendOrganization(); + sendObservation(code, "SNOMED-CT"); + sendPatient(); + + waitForQueueToDrain(); + + // Should see 1 subscription notification for each type + ourObservationProvider.waitForCreateCount(0); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + ourPatientProvider.waitForCreateCount(0); + ourPatientProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(1)); + ourOrganizationProvider.waitForCreateCount(0); + ourOrganizationProvider.waitForUpdateCount(0); + + } + + @Test + public void testSubscriptionTriggerViaSubscription() throws Exception { + String payload = "application/xml"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + + createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + ourLog.info("** About to send observation"); + + Observation observation = new Observation(); + observation.addIdentifier().setSystem("foo").setValue("bar1"); + observation.setId(IdType.newRandomUuid().getValue()); + CodeableConcept codeableConcept = new CodeableConcept() + .addCoding(new Coding().setCode(code).setSystem("SNOMED-CT")); + observation.setCode(codeableConcept); + observation.setStatus(Enumerations.ObservationStatus.FINAL); + + Patient patient = new Patient(); + patient.addIdentifier().setSystem("foo").setValue("bar2"); + patient.setId(IdType.newRandomUuid().getValue()); + patient.setActive(true); + observation.getSubject().setReference(patient.getId()); + + Bundle requestBundle = new Bundle(); + requestBundle.setType(Bundle.BundleType.TRANSACTION); + requestBundle.addEntry() + .setResource(observation) + .setFullUrl(observation.getId()) + .getRequest() + .setUrl("Observation?identifier=foo|bar1") + .setMethod(Bundle.HTTPVerb.PUT); + requestBundle.addEntry() + .setResource(patient) + .setFullUrl(patient.getId()) + .getRequest() + .setUrl("Patient?identifier=foo|bar2") + .setMethod(Bundle.HTTPVerb.PUT); + myClient.transaction().withBundle(requestBundle).execute(); + + // Should see 1 subscription notification + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_XML_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + + Observation obs = ourObservationProvider.getStoredResources().get(0); + ourLog.debug("Observation content: {}", myFhirContext.newXmlParser().setPrettyPrint(true).encodeResourceToString(obs)); + } + + @Test + public void testUpdateSubscriptionToMatchLater() throws Exception { + String payload = "application/xml"; + + String code = "1000000050"; + String criteriaBad = "Observation?code=SNOMED-CT|" + code + "111&_format=xml"; + + ourLog.info("** About to create non-matching subscription"); + + Subscription subscription2 = createSubscription(criteriaBad, payload); + + ourLog.info("** About to send observation that wont match"); + + Observation observation1 = sendObservation(code, "SNOMED-CT"); + + // Criteria didn't match, shouldn't see any updates + waitForQueueToDrain(); + Thread.sleep(1000); + assertEquals(0, ourObservationProvider.getCountUpdate()); + + Subscription subscriptionTemp = myClient.read().resource(Subscription.class).withId(subscription2.getId()).execute(); + assertNotNull(subscriptionTemp); + String criteriaGood = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + subscriptionTemp.setCriteria(criteriaGood); + ourLog.info("** About to update subscription"); + myClient.update().resource(subscriptionTemp).withId(subscriptionTemp.getIdElement()).execute(); + waitForQueueToDrain(); + + ourLog.info("** About to send Observation 2"); + Observation observation2 = sendObservation(code, "SNOMED-CT"); + waitForQueueToDrain(); + + // Should see a subscription notification this time + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + + myClient.delete().resourceById(new IdType("Subscription/" + subscription2.getId())).execute(); + + Observation observationTemp3 = sendObservation(code, "SNOMED-CT"); + + // No more matches + Thread.sleep(1000); + assertEquals(1, ourObservationProvider.getCountUpdate()); + } + + @Test + public void testRestHookSubscriptionApplicationXmlJson() throws Exception { + String payload = "application/fhir+xml"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + String criteria2 = "Observation?code=SNOMED-CT|" + code + "111&_format=xml"; + + Subscription subscription1 = createSubscription(criteria1, payload); + Subscription subscription2 = createSubscription(criteria2, payload); + waitForActivatedSubscriptionCount(2); + + Observation observation1 = sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_XML_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + } + + @Test + public void testRestHookSubscriptionInvalidCriteria() throws Exception { + String payload = "application/xml"; + + String criteria1 = "Observation?codeeeee=SNOMED-CT"; + + try { + createSubscription(criteria1, payload); + fail(); + } catch (UnprocessableEntityException e) { + assertEquals("HTTP 422 Unprocessable Entity: " + Msg.code(9) + "Invalid subscription criteria submitted: Observation?codeeeee=SNOMED-CT " + Msg.code(488) + "Failed to parse match URL[Observation?codeeeee=SNOMED-CT] - Resource type Observation does not have a parameter with name: codeeeee", e.getMessage()); + } + } + + @Test + public void testSubscriptionWithHeaders() throws Exception { + String payload = "application/fhir+json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + + // Add some headers, and we'll also turn back to requested status for fun + Subscription subscription = createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + subscription.getChannel().addHeader("X-Foo: FOO"); + subscription.getChannel().addHeader("X-Bar: BAR"); + subscription.setStatus(Enumerations.SubscriptionStatus.REQUESTED); + myClient.update().resource(subscription).execute(); + waitForQueueToDrain(); + + sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + assertEquals(Constants.CT_FHIR_JSON_NEW, ourRestfulServer.getRequestContentTypes().get(0)); + assertThat(ourRestfulServer.getRequestHeaders().get(0), hasItem("X-Foo: FOO")); + assertThat(ourRestfulServer.getRequestHeaders().get(0), hasItem("X-Bar: BAR")); + } + + @Test + public void testDisableSubscription() throws Exception { + String payload = "application/fhir+json"; + + String code = "1000000050"; + String criteria1 = "Observation?code=SNOMED-CT|" + code + "&_format=xml"; + + Subscription subscription = createSubscription(criteria1, payload); + waitForActivatedSubscriptionCount(1); + + sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + + // Disable + subscription.setStatus(Enumerations.SubscriptionStatus.OFF); + myClient.update().resource(subscription).execute(); + waitForQueueToDrain(); + + // Send another object + sendObservation(code, "SNOMED-CT"); + + // Should see 1 subscription notification + waitForQueueToDrain(); + assertEquals(0, ourObservationProvider.getCountCreate()); + ourObservationProvider.waitForUpdateCount(1); + + } + + @Test + public void testInvalidProvenanceParam() { + assertThrows(UnprocessableEntityException.class, () -> { + String payload = "application/fhir+json"; + String criteriabad = "Provenance?activity=http://hl7.org/fhir/v3/DocumentCompletion%7CAU"; + Subscription subscription = newSubscription(criteriabad, payload); + myClient.create().resource(subscription).execute(); + }); + } + + @Test + public void testInvalidProcedureRequestParam() { + assertThrows(UnprocessableEntityException.class, () -> { + String payload = "application/fhir+json"; + String criteriabad = "ProcedureRequest?intent=instance-order&category=Laboratory"; + Subscription subscription = newSubscription(criteriabad, payload); + myClient.create().resource(subscription).execute(); + }); + } + + @Test + public void testInvalidBodySiteParam() { + assertThrows(UnprocessableEntityException.class, () -> { + String payload = "application/fhir+json"; + String criteriabad = "BodySite?accessType=Catheter"; + Subscription subscription = newSubscription(criteriabad, payload); + myClient.create().resource(subscription).execute(); + }); + } + + @Test + public void testGoodSubscriptionPersists() { + assertEquals(0, subscriptionCount()); + String payload = "application/fhir+json"; + String criteriaGood = "Patient?gender=male"; + Subscription subscription = newSubscription(criteriaGood, payload); + myClient.create().resource(subscription).execute(); + await().until(() -> subscriptionCount() == 1); + } + + /** + * Make sure we don't activate a subscription if its type is incorrect + */ + @Test + public void testSubscriptionDoesntActivateIfRestHookIsNotEnabled() throws InterruptedException { + Set existingSupportedSubscriptionTypes = myStorageSettings.getSupportedSubscriptionTypes(); + myStorageSettings.clearSupportedSubscriptionTypesForUnitTest(); + try { + + Subscription subscription = newSubscriptionWithStatus("Observation?", "application/fhir+json", Enumerations.SubscriptionStatus.REQUESTED); + IIdType id = myClient.create().resource(subscription).execute().getId().toUnqualifiedVersionless(); + + Thread.sleep(1000); + subscription = myClient.read().resource(Subscription.class).withId(id).execute(); + assertEquals(Enumerations.SubscriptionStatus.REQUESTED, subscription.getStatus()); + + } finally { + existingSupportedSubscriptionTypes.forEach(t -> myStorageSettings.addSupportedSubscriptionType(t)); + } + } + + + private int subscriptionCount() { + IBaseBundle found = myClient.search().forResource(Subscription.class).cacheControl(new CacheControlDirective().setNoCache(true)).execute(); + return toUnqualifiedVersionlessIdValues(found).size(); + } + + @Test + public void testSubscriptionWithNoStatusIsRejected() { + Subscription subscription = newSubscription("Observation?", "application/json"); + subscription.setStatus(null); + + try { + myClient.create().resource(subscription).execute(); + fail(); + } catch (UnprocessableEntityException e) { + assertThat(e.getMessage(), containsString("Can not process submitted Subscription - Subscription.status must be populated on this server")); + } + } + + + @Test + public void testBadSubscriptionDoesntPersist() { + assertEquals(0, subscriptionCount()); + String payload = "application/fhir+json"; + String criteriaBad = "BodySite?accessType=Catheter"; + Subscription subscription = newSubscription(criteriaBad, payload); + try { + myClient.create().resource(subscription).execute(); + } catch (UnprocessableEntityException e) { + ourLog.info("Expected exception", e); + } + assertEquals(0, subscriptionCount()); + } + + @Test + public void testCustomSearchParam() throws Exception { + String criteria = "Observation?accessType=Catheter,PD%20Catheter"; + + SearchParameter sp = new SearchParameter(); + sp.addBase("Observation"); + sp.setCode("accessType"); + sp.setType(Enumerations.SearchParamType.TOKEN); + sp.setExpression("Observation.extension('Observation#accessType')"); + sp.setXpathUsage(SearchParameter.XPathUsageType.NORMAL); + sp.setStatus(Enumerations.PublicationStatus.ACTIVE); + mySearchParameterDao.create(sp); + mySearchParamRegistry.forceRefresh(); + createSubscription(criteria, "application/json"); + waitForActivatedSubscriptionCount(1); + + { + Observation bodySite = new Observation(); + bodySite.addExtension().setUrl("Observation#accessType").setValue(new Coding().setCode("Catheter")); + MethodOutcome methodOutcome = myClient.create().resource(bodySite).execute(); + assertEquals(true, methodOutcome.getCreated()); + waitForQueueToDrain(); + ourObservationProvider.waitForUpdateCount(1); + } + { + Observation observation = new Observation(); + observation.addExtension().setUrl("Observation#accessType").setValue(new Coding().setCode("PD Catheter")); + MethodOutcome methodOutcome = myClient.create().resource(observation).execute(); + assertEquals(true, methodOutcome.getCreated()); + waitForQueueToDrain(); + ourObservationProvider.waitForUpdateCount(2); + } + { + Observation observation = new Observation(); + MethodOutcome methodOutcome = myClient.create().resource(observation).execute(); + assertEquals(true, methodOutcome.getCreated()); + waitForQueueToDrain(); + ourObservationProvider.waitForUpdateCount(2); + } + { + Observation observation = new Observation(); + observation.addExtension().setUrl("Observation#accessType").setValue(new Coding().setCode("XXX")); + MethodOutcome methodOutcome = myClient.create().resource(observation).execute(); + assertEquals(true, methodOutcome.getCreated()); + waitForQueueToDrain(); + ourObservationProvider.waitForUpdateCount(2); + } + + } + + + @Test + public void testDeliverSearchResult() throws Exception { + { + Subscription subscription = newSubscription("Observation?", "application/json"); + subscription.addExtension(HapiExtensions.EXT_SUBSCRIPTION_PAYLOAD_SEARCH_CRITERIA, new StringType("Observation?_id=${matched_resource_id}&_include=*")); + ourLog.debug(myFhirContext.newJsonParser().setPrettyPrint(true).encodeResourceToString(subscription)); + MethodOutcome methodOutcome = myClient.create().resource(subscription).execute(); + mySubscriptionIds.add(methodOutcome.getId()); + waitForActivatedSubscriptionCount(1); + } + + { + Patient patient = new Patient(); + patient.setActive(true); + IIdType patientId = myClient.create().resource(patient).execute().getId(); + + Observation observation = new Observation(); + observation.addExtension().setUrl("Observation#accessType").setValue(new Coding().setCode("Catheter")); + observation.getSubject().setReferenceElement(patientId.toUnqualifiedVersionless()); + MethodOutcome methodOutcome = myClient.create().resource(observation).execute(); + assertEquals(true, methodOutcome.getCreated()); + + waitForQueueToDrain(); + + ourTransactionProvider.waitForTransactionCount(1); + + Bundle xact = ourTransactionProvider.getTransactions().get(0); + assertEquals(2, xact.getEntry().size()); + } + + } + +} diff --git a/hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionTopicR4BTest.java b/hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionTopicR4BTest.java new file mode 100644 index 00000000000..0f1d7237e04 --- /dev/null +++ b/hapi-fhir-jpaserver-test-r4b/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionTopicR4BTest.java @@ -0,0 +1,243 @@ +package ca.uhn.fhir.jpa.subscription; + +import ca.uhn.fhir.interceptor.api.IInterceptorService; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionConstants; +import ca.uhn.fhir.jpa.topic.SubscriptionTopicLoader; +import ca.uhn.fhir.jpa.topic.SubscriptionTopicRegistry; +import ca.uhn.fhir.rest.annotation.Transaction; +import ca.uhn.fhir.rest.annotation.TransactionParam; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.util.BundleUtil; +import ca.uhn.test.concurrency.PointcutLatch; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4b.model.Bundle; +import org.hl7.fhir.r4b.model.Encounter; +import org.hl7.fhir.r4b.model.Enumerations; +import org.hl7.fhir.r4b.model.Subscription; +import org.hl7.fhir.r4b.model.SubscriptionStatus; +import org.hl7.fhir.r4b.model.SubscriptionTopic; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class SubscriptionTopicR4BTest extends BaseSubscriptionsR4BTest { + private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionTopicR4BTest.class); + public static final String SUBSCRIPTION_TOPIC_TEST_URL = "http://example.com/topic/test"; + + @Autowired + protected SubscriptionTopicRegistry mySubscriptionTopicRegistry; + @Autowired + protected SubscriptionTopicLoader mySubscriptionTopicLoader; + @Autowired + private IInterceptorService myInterceptorService; + protected IFhirResourceDao mySubscriptionTopicDao; + private static final TestSystemProvider ourTestSystemProvider = new TestSystemProvider(); + + private final PointcutLatch mySubscriptionTopicsCheckedLatch = new PointcutLatch(Pointcut.SUBSCRIPTION_TOPIC_AFTER_PERSISTED_RESOURCE_CHECKED); + private final PointcutLatch mySubscriptionDeliveredLatch = new PointcutLatch(Pointcut.SUBSCRIPTION_AFTER_REST_HOOK_DELIVERY); + + @Override + @BeforeEach + protected void before() throws Exception { + super.before(); + ourRestfulServer.unregisterProvider(mySystemProvider); + ourRestfulServer.registerProvider(ourTestSystemProvider); + mySubscriptionTopicDao = myDaoRegistry.getResourceDao(SubscriptionTopic.class); + myInterceptorService.registerAnonymousInterceptor(Pointcut.SUBSCRIPTION_TOPIC_AFTER_PERSISTED_RESOURCE_CHECKED, mySubscriptionTopicsCheckedLatch); + myInterceptorService.registerAnonymousInterceptor(Pointcut.SUBSCRIPTION_AFTER_REST_HOOK_DELIVERY, mySubscriptionDeliveredLatch); + } + + @Override + @AfterEach + public void after() throws Exception { + ourRestfulServer.unregisterProvider(ourTestSystemProvider); + ourRestfulServer.registerProvider(mySystemProvider); + myInterceptorService.unregisterAllAnonymousInterceptors(); + mySubscriptionTopicsCheckedLatch.clear(); + mySubscriptionDeliveredLatch.clear(); + ourTestSystemProvider.clear(); + super.after(); + } + + @Test + public void testCreate() throws Exception { + // WIP SR4B test update, delete, etc + createEncounterSubscriptionTopic(Encounter.EncounterStatus.PLANNED, Encounter.EncounterStatus.FINISHED, SubscriptionTopic.InteractionTrigger.CREATE); + mySubscriptionTopicLoader.doSyncResourcessForUnitTest(); + waitForRegisteredSubscriptionTopicCount(1); + + Subscription subscription = createTopicSubscription(SUBSCRIPTION_TOPIC_TEST_URL); + waitForActivatedSubscriptionCount(1); + + assertEquals(0, ourTestSystemProvider.getCount()); + Encounter sentEncounter = sendEncounterWithStatus(Encounter.EncounterStatus.FINISHED, true); + + assertEquals(1, ourTestSystemProvider.getCount()); + + Bundle receivedBundle = ourTestSystemProvider.getLastInput(); + List resources = BundleUtil.toListOfResources(myFhirCtx, receivedBundle); + assertEquals(2, resources.size()); + + SubscriptionStatus ss = (SubscriptionStatus) resources.get(0); + validateSubscriptionStatus(subscription, sentEncounter, ss); + + Encounter encounter = (Encounter) resources.get(1); + assertEquals(Encounter.EncounterStatus.FINISHED, encounter.getStatus()); + assertEquals(sentEncounter.getIdElement(), encounter.getIdElement()); + } + + @Test + public void testUpdate() throws Exception { + // WIP SR4B test update, delete, etc + createEncounterSubscriptionTopic(Encounter.EncounterStatus.PLANNED, Encounter.EncounterStatus.FINISHED, SubscriptionTopic.InteractionTrigger.CREATE, SubscriptionTopic.InteractionTrigger.UPDATE); + mySubscriptionTopicLoader.doSyncResourcessForUnitTest(); + waitForRegisteredSubscriptionTopicCount(1); + + Subscription subscription = createTopicSubscription(SUBSCRIPTION_TOPIC_TEST_URL); + waitForActivatedSubscriptionCount(1); + + assertEquals(0, ourTestSystemProvider.getCount()); + Encounter sentEncounter = sendEncounterWithStatus(Encounter.EncounterStatus.PLANNED, false); + assertEquals(0, ourTestSystemProvider.getCount()); + + sentEncounter.setStatus(Encounter.EncounterStatus.FINISHED); + updateEncounter(sentEncounter, true); + + assertEquals(1, ourTestSystemProvider.getCount()); + + Bundle receivedBundle = ourTestSystemProvider.getLastInput(); + List resources = BundleUtil.toListOfResources(myFhirCtx, receivedBundle); + assertEquals(2, resources.size()); + + SubscriptionStatus ss = (SubscriptionStatus) resources.get(0); + validateSubscriptionStatus(subscription, sentEncounter, ss); + + Encounter encounter = (Encounter) resources.get(1); + assertEquals(Encounter.EncounterStatus.FINISHED, encounter.getStatus()); + assertEquals(sentEncounter.getIdElement(), encounter.getIdElement()); + } + + + private static void validateSubscriptionStatus(Subscription subscription, Encounter sentEncounter, SubscriptionStatus ss) { + assertEquals(Enumerations.SubscriptionStatus.ACTIVE, ss.getStatus()); + assertEquals(SubscriptionStatus.SubscriptionNotificationType.EVENTNOTIFICATION, ss.getType()); + assertEquals("1", ss.getEventsSinceSubscriptionStartElement().getValueAsString()); + + List notificationEvents = ss.getNotificationEvent(); + assertEquals(1, notificationEvents.size()); + SubscriptionStatus.SubscriptionStatusNotificationEventComponent notificationEvent = notificationEvents.get(0); + assertEquals("1", notificationEvent.getEventNumber()); + assertEquals(sentEncounter.getIdElement().toUnqualifiedVersionless(), notificationEvent.getFocus().getReferenceElement()); + + assertEquals(subscription.getIdElement().toUnqualifiedVersionless(), ss.getSubscription().getReferenceElement()); + assertEquals(SUBSCRIPTION_TOPIC_TEST_URL, ss.getTopic()); + } + + private Subscription createTopicSubscription(String theTopicUrl) throws InterruptedException { + Subscription subscription = newSubscription(theTopicUrl, Constants.CT_FHIR_JSON_NEW); + subscription.getMeta().addProfile(SubscriptionConstants.SUBSCRIPTION_TOPIC_PROFILE_URL); + + mySubscriptionTopicsCheckedLatch.setExpectedCount(1); + Subscription retval = postOrPutSubscription(subscription); + mySubscriptionTopicsCheckedLatch.awaitExpected(); + + return retval; + } + + private void waitForRegisteredSubscriptionTopicCount(int theTarget) throws Exception { + await().until(() -> subscriptionTopicRegistryHasSize(theTarget)); + } + + private boolean subscriptionTopicRegistryHasSize(int theTarget) { + int size = mySubscriptionTopicRegistry.size(); + if (size == theTarget) { + return true; + } + mySubscriptionTopicLoader.doSyncResourcessForUnitTest(); + return mySubscriptionTopicRegistry.size() == theTarget; + } + + private SubscriptionTopic createEncounterSubscriptionTopic(Encounter.EncounterStatus theFrom, Encounter.EncounterStatus theCurrent, SubscriptionTopic.InteractionTrigger... theInteractionTriggers) { + SubscriptionTopic retval = new SubscriptionTopic(); + retval.setUrl(SUBSCRIPTION_TOPIC_TEST_URL); + retval.setStatus(Enumerations.PublicationStatus.ACTIVE); + SubscriptionTopic.SubscriptionTopicResourceTriggerComponent trigger = retval.addResourceTrigger(); + trigger.setResource("Encounter"); + for (SubscriptionTopic.InteractionTrigger interactionTrigger : theInteractionTriggers) { + trigger.addSupportedInteraction(interactionTrigger); + } + SubscriptionTopic.SubscriptionTopicResourceTriggerQueryCriteriaComponent queryCriteria = trigger.getQueryCriteria(); + queryCriteria.setPrevious("Encounter?status=" + theFrom.toCode()); + queryCriteria.setCurrent("Encounter?status=" + theCurrent.toCode()); + queryCriteria.setRequireBoth(true); + mySubscriptionTopicDao.create(retval, mySrd); + return retval; + } + + private Encounter sendEncounterWithStatus(Encounter.EncounterStatus theStatus, boolean theExpectDelivery) throws InterruptedException { + Encounter encounter = new Encounter(); + encounter.setStatus(theStatus); + + if (theExpectDelivery) { + mySubscriptionDeliveredLatch.setExpectedCount(1); + } + mySubscriptionTopicsCheckedLatch.setExpectedCount(1); + IIdType id = myEncounterDao.create(encounter, mySrd).getId(); + mySubscriptionTopicsCheckedLatch.awaitExpected(); + if (theExpectDelivery) { + mySubscriptionDeliveredLatch.awaitExpected(); + } + encounter.setId(id); + return encounter; + } + + private Encounter updateEncounter(Encounter theEncounter, boolean theExpectDelivery) throws InterruptedException { + if (theExpectDelivery) { + mySubscriptionDeliveredLatch.setExpectedCount(1); + } + mySubscriptionTopicsCheckedLatch.setExpectedCount(1); + Encounter retval = (Encounter) myEncounterDao.update(theEncounter, mySrd).getResource(); + mySubscriptionTopicsCheckedLatch.awaitExpected(); + if (theExpectDelivery) { + mySubscriptionDeliveredLatch.awaitExpected(); + } + return retval; + } + + static class TestSystemProvider { + final AtomicInteger myCount = new AtomicInteger(0); + Bundle myLastInput; + + @Transaction + public Bundle transaction(@TransactionParam Bundle theInput) { + myCount.incrementAndGet(); + myLastInput = theInput; + return theInput; + } + + public int getCount() { + return myCount.get(); + } + + public Bundle getLastInput() { + return myLastInput; + } + + public void clear() { + myCount.set(0); + myLastInput = null; + } + } +} diff --git a/hapi-fhir-jpaserver-test-r5/src/test/java/ca/uhn/fhir/jpa/subscription/BaseSubscriptionsR5Test.java b/hapi-fhir-jpaserver-test-r5/src/test/java/ca/uhn/fhir/jpa/subscription/BaseSubscriptionsR5Test.java index 9a76358810b..bcceda76b8e 100644 --- a/hapi-fhir-jpaserver-test-r5/src/test/java/ca/uhn/fhir/jpa/subscription/BaseSubscriptionsR5Test.java +++ b/hapi-fhir-jpaserver-test-r5/src/test/java/ca/uhn/fhir/jpa/subscription/BaseSubscriptionsR5Test.java @@ -39,6 +39,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.springframework.beans.factory.annotation.Autowired; +import javax.annotation.Nonnull; import javax.annotation.PostConstruct; import javax.servlet.http.HttpServletRequest; import java.util.ArrayList; @@ -57,6 +58,7 @@ public abstract class BaseSubscriptionsR5Test extends BaseResourceProviderR5Test private static Server ourListenerServer; private static SingleQueryCountHolder ourCountHolder; private static String ourListenerServerBase; + protected static RestfulServer ourListenerRestServer; @Autowired protected SubscriptionTestUtil mySubscriptionTestUtil; @Autowired @@ -120,8 +122,13 @@ public abstract class BaseSubscriptionsR5Test extends BaseResourceProviderR5Test protected Subscription createSubscription(String theCriteria, String thePayload) { Subscription subscription = newSubscription(theCriteria, thePayload); + return postSubscription(subscription); + } + + @Nonnull + protected Subscription postSubscription(Subscription subscription) { MethodOutcome methodOutcome = myClient.create().resource(subscription).execute(); - subscription.setId(methodOutcome.getId().getIdPart()); + subscription.setId(methodOutcome.getId().toVersionless()); mySubscriptionIds.add(methodOutcome.getId()); return subscription; @@ -225,7 +232,7 @@ public abstract class BaseSubscriptionsR5Test extends BaseResourceProviderR5Test @BeforeAll public static void startListenerServer() throws Exception { - RestfulServer ourListenerRestServer = new RestfulServer(FhirContext.forR5Cached()); + ourListenerRestServer = new RestfulServer(FhirContext.forR5Cached()); ObservationListener obsListener = new ObservationListener(); ourListenerRestServer.setResourceProviders(obsListener); diff --git a/hapi-fhir-jpaserver-test-r5/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionTopicR5Test.java b/hapi-fhir-jpaserver-test-r5/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionTopicR5Test.java new file mode 100644 index 00000000000..03b2775222e --- /dev/null +++ b/hapi-fhir-jpaserver-test-r5/src/test/java/ca/uhn/fhir/jpa/subscription/SubscriptionTopicR5Test.java @@ -0,0 +1,169 @@ +package ca.uhn.fhir.jpa.subscription; + +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionConstants; +import ca.uhn.fhir.jpa.topic.SubscriptionTopicLoader; +import ca.uhn.fhir.jpa.topic.SubscriptionTopicRegistry; +import ca.uhn.fhir.rest.annotation.Transaction; +import ca.uhn.fhir.rest.annotation.TransactionParam; +import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.util.BundleUtil; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r5.model.Bundle; +import org.hl7.fhir.r5.model.Encounter; +import org.hl7.fhir.r5.model.Enumerations; +import org.hl7.fhir.r5.model.Subscription; +import org.hl7.fhir.r5.model.SubscriptionStatus; +import org.hl7.fhir.r5.model.SubscriptionTopic; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class SubscriptionTopicR5Test extends BaseSubscriptionsR5Test { + private static final Logger ourLog = LoggerFactory.getLogger(SubscriptionTopicR5Test.class); + public static final String SUBSCRIPTION_TOPIC_TEST_URL = "http://example.com/topic/test"; + + @Autowired + protected SubscriptionTopicRegistry mySubscriptionTopicRegistry; + @Autowired + protected SubscriptionTopicLoader mySubscriptionTopicLoader; + protected IFhirResourceDao mySubscriptionTopicDao; + private static final TestSystemProvider ourTestSystemProvider = new TestSystemProvider(); + + @Override + @BeforeEach + protected void before() throws Exception { + super.before(); + ourListenerRestServer.unregisterProvider(mySystemProvider); + ourListenerRestServer.registerProvider(ourTestSystemProvider); + mySubscriptionTopicDao = myDaoRegistry.getResourceDao(SubscriptionTopic.class); + } + + @Override + @AfterEach + public void after() throws Exception { + ourListenerRestServer.unregisterProvider(ourTestSystemProvider); + ourListenerRestServer.registerProvider(mySystemProvider); + super.after(); + } + + @Test + public void testRestHookSubscriptionTopicApplicationFhirJson() throws Exception { + // WIP SR4B test update, delete, etc + createEncounterSubscriptionTopic(Encounter.EncounterStatus.PLANNED, Encounter.EncounterStatus.COMPLETED, SubscriptionTopic.InteractionTrigger.CREATE); + waitForRegisteredSubscriptionTopicCount(1); + + Subscription subscription = createTopicSubscription(SUBSCRIPTION_TOPIC_TEST_URL); + waitForActivatedSubscriptionCount(1); + + assertEquals(0, ourTestSystemProvider.getCount()); + Encounter sentEncounter = sendEncounterWithStatus(Encounter.EncounterStatus.COMPLETED); + + // Should see 1 subscription notification + waitForQueueToDrain(); + + await().until(() -> ourTestSystemProvider.getCount() > 0); + + Bundle receivedBundle = ourTestSystemProvider.getLastInput(); + List resources = BundleUtil.toListOfResources(myFhirCtx, receivedBundle); + assertEquals(2, resources.size()); + + SubscriptionStatus ss = (SubscriptionStatus) resources.get(0); + validateSubscriptionStatus(subscription, sentEncounter, ss); + + Encounter encounter = (Encounter) resources.get(1); + assertEquals(Encounter.EncounterStatus.COMPLETED, encounter.getStatus()); + assertEquals(sentEncounter.getIdElement(), encounter.getIdElement()); + } + + private static void validateSubscriptionStatus(Subscription subscription, Encounter sentEncounter, SubscriptionStatus ss) { + assertEquals(Enumerations.SubscriptionStatusCodes.ACTIVE, ss.getStatus()); + assertEquals(SubscriptionStatus.SubscriptionNotificationType.EVENTNOTIFICATION, ss.getType()); + assertEquals("1", ss.getEventsSinceSubscriptionStartElement().getValueAsString()); + + List notificationEvents = ss.getNotificationEvent(); + assertEquals(1, notificationEvents.size()); + SubscriptionStatus.SubscriptionStatusNotificationEventComponent notificationEvent = notificationEvents.get(0); + assertEquals(1, notificationEvent.getEventNumber()); + assertEquals(sentEncounter.getIdElement().toUnqualifiedVersionless(), notificationEvent.getFocus().getReferenceElement()); + + assertEquals(subscription.getIdElement().toUnqualifiedVersionless(), ss.getSubscription().getReferenceElement()); + assertEquals(SUBSCRIPTION_TOPIC_TEST_URL, ss.getTopic()); + } + + private Subscription createTopicSubscription(String theTopicUrl) { + Subscription subscription = newSubscription(theTopicUrl, Constants.CT_FHIR_JSON_NEW); + subscription.getMeta().addProfile(SubscriptionConstants.SUBSCRIPTION_TOPIC_PROFILE_URL); + return postSubscription(subscription); + } + + private void waitForRegisteredSubscriptionTopicCount(int theTarget) throws Exception { + await().until(() -> subscriptionTopicRegistryHasSize(theTarget)); + } + + private boolean subscriptionTopicRegistryHasSize(int theTarget) { + int size = mySubscriptionTopicRegistry.size(); + if (size == theTarget) { + return true; + } + mySubscriptionTopicLoader.doSyncResourcessForUnitTest(); + return mySubscriptionTopicRegistry.size() == theTarget; + } + + private SubscriptionTopic createEncounterSubscriptionTopic(Encounter.EncounterStatus theFrom, Encounter.EncounterStatus theCurrent, SubscriptionTopic.InteractionTrigger... theInteractionTriggers) { + SubscriptionTopic retval = new SubscriptionTopic(); + retval.setUrl(SUBSCRIPTION_TOPIC_TEST_URL); + retval.setStatus(Enumerations.PublicationStatus.ACTIVE); + SubscriptionTopic.SubscriptionTopicResourceTriggerComponent trigger = retval.addResourceTrigger(); + trigger.setResource("Encounter"); + for (SubscriptionTopic.InteractionTrigger interactionTrigger : theInteractionTriggers) { + trigger.addSupportedInteraction(interactionTrigger); + } + SubscriptionTopic.SubscriptionTopicResourceTriggerQueryCriteriaComponent queryCriteria = trigger.getQueryCriteria(); + queryCriteria.setPrevious("Encounter?status=" + theFrom.toCode()); + queryCriteria.setCurrent("Encounter?status=" + theCurrent.toCode()); + queryCriteria.setRequireBoth(true); + queryCriteria.setRequireBoth(true); + mySubscriptionTopicDao.create(retval, mySrd); + return retval; + } + + private Encounter sendEncounterWithStatus(Encounter.EncounterStatus theStatus) { + Encounter encounter = new Encounter(); + encounter.setStatus(theStatus); + + IIdType id = myEncounterDao.create(encounter, mySrd).getId(); + encounter.setId(id); + return encounter; + } + + static class TestSystemProvider { + AtomicInteger myCount = new AtomicInteger(0); + Bundle myLastInput; + + @Transaction + public Bundle transaction(@TransactionParam Bundle theInput) { + myCount.incrementAndGet(); + myLastInput = theInput; + return theInput; + } + + public int getCount() { + return myCount.get(); + } + + public Bundle getLastInput() { + return myLastInput; + } + } +} diff --git a/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/test/BaseJpaTest.java b/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/test/BaseJpaTest.java index d4b1d05d62f..1f84be24bd7 100644 --- a/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/test/BaseJpaTest.java +++ b/hapi-fhir-jpaserver-test-utilities/src/main/java/ca/uhn/fhir/jpa/test/BaseJpaTest.java @@ -129,6 +129,8 @@ import org.springframework.transaction.support.TransactionTemplate; import javax.annotation.Nonnull; import javax.persistence.EntityManager; import java.io.IOException; +import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -836,18 +838,10 @@ public abstract class BaseJpaTest extends BaseTest { } @SuppressWarnings("BusyWait") - public static void waitForSize(int theTarget, int theTimeout, Callable theCallable, Callable theFailureMessage) throws Exception { - StopWatch sw = new StopWatch(); - while (theCallable.call().intValue() != theTarget && sw.getMillis() < theTimeout) { - try { - Thread.sleep(50); - } catch (InterruptedException theE) { - throw new Error(theE); - } - } - if (sw.getMillis() >= theTimeout) { - fail("Size " + theCallable.call() + " is != target " + theTarget + " - " + theFailureMessage.call()); - } - Thread.sleep(500); + public static void waitForSize(int theTarget, int theTimeoutMillis, Callable theCallable, Callable theFailureMessage) throws Exception { + await() + .alias("Waiting for size " + theTarget + ". Current size is " + theCallable.call().intValue() + ": " + theFailureMessage.call()) + .atMost(Duration.of(theTimeoutMillis, ChronoUnit.MILLIS)) + .until(() -> theCallable.call().intValue() == theTarget); } } diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/SimpleBundleProvider.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/SimpleBundleProvider.java index c5f57ee4977..1b4eb63dd32 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/SimpleBundleProvider.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/SimpleBundleProvider.java @@ -21,6 +21,7 @@ package ca.uhn.fhir.rest.server; import ca.uhn.fhir.model.primitive.InstantDt; import ca.uhn.fhir.rest.api.server.IBundleProvider; +import org.apache.commons.lang3.builder.ToStringBuilder; import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IPrimitiveType; @@ -167,4 +168,10 @@ public class SimpleBundleProvider implements IBundleProvider { return mySize; } + @Override + public String toString() { + return new ToStringBuilder(this) + .append("mySize", mySize) + .toString(); + } } diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/cache/BaseResourceCacheSynchronizer.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/cache/BaseResourceCacheSynchronizer.java new file mode 100644 index 00000000000..ee1d8d3da04 --- /dev/null +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/cache/BaseResourceCacheSynchronizer.java @@ -0,0 +1,204 @@ +package ca.uhn.fhir.cache; + +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.cache.IResourceChangeEvent; +import ca.uhn.fhir.jpa.cache.IResourceChangeListener; +import ca.uhn.fhir.jpa.cache.IResourceChangeListenerCache; +import ca.uhn.fhir.jpa.cache.IResourceChangeListenerRegistry; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.jpa.searchparam.retry.Retrier; +import ca.uhn.fhir.jpa.subscription.match.registry.SubscriptionConstants; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import ca.uhn.fhir.rest.api.server.SystemRequestDetails; +import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; +import com.google.common.annotations.VisibleForTesting; +import org.apache.commons.lang3.time.DateUtils; +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 org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; + +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Semaphore; +import java.util.stream.Collectors; + +public abstract class BaseResourceCacheSynchronizer implements IResourceChangeListener { + private static final Logger ourLog = LoggerFactory.getLogger(BaseResourceCacheSynchronizer.class); + public static final int MAX_RETRIES = 60; // 60 * 5 seconds = 5 minutes + public static final long REFRESH_INTERVAL = DateUtils.MILLIS_PER_MINUTE; + private final String myResourceName; + + @Autowired + protected ISearchParamRegistry mySearchParamRegistry; + @Autowired + private IResourceChangeListenerRegistry myResourceChangeListenerRegistry; + @Autowired + DaoRegistry myDaoRegistry; + + private SearchParameterMap mySearchParameterMap; + private SystemRequestDetails mySystemRequestDetails; + private boolean myStopping; + private boolean myEnabled; + private final Semaphore mySyncResourcesSemaphore = new Semaphore(1); + private final Object mySyncResourcesLock = new Object(); + + public BaseResourceCacheSynchronizer(String theResourceName) { + myResourceName = theResourceName; + } + + @PostConstruct + public void registerListener() { + if (myDaoRegistry.getResourceDaoOrNull(myResourceName) == null) { + ourLog.info("No resource DAO found for resource type {}, not registering listener", myResourceName); + return; + } + mySearchParameterMap = getSearchParameterMap(); + mySystemRequestDetails = SystemRequestDetails.forAllPartitions(); + + IResourceChangeListenerCache resourceCache = myResourceChangeListenerRegistry.registerResourceResourceChangeListener(myResourceName, mySearchParameterMap, this, REFRESH_INTERVAL); + resourceCache.forceRefresh(); + myEnabled = true; + } + + @PreDestroy + public void unregisterListener() { + myResourceChangeListenerRegistry.unregisterResourceResourceChangeListener(this); + } + + private boolean resourceDaoExists() { + return myDaoRegistry != null && myDaoRegistry.isResourceTypeSupported(myResourceName); + } + + /** + * Read the existing resources from the database + */ + public void syncDatabaseToCache() { + if (!myEnabled) { + return; + } + if (!resourceDaoExists()) { + return; + } + if (!mySyncResourcesSemaphore.tryAcquire()) { + return; + } + try { + doSyncResourcesWithRetry(); + } finally { + mySyncResourcesSemaphore.release(); + } + } + + @VisibleForTesting + public void acquireSemaphoreForUnitTest() throws InterruptedException { + if (!myEnabled) { + return; + } + mySyncResourcesSemaphore.acquire(); + } + + @VisibleForTesting + public int doSyncResourcessForUnitTest() { + if (!myEnabled) { + return 0; + } + // Two passes for delete flag to take effect + int first = doSyncResourcesWithRetry(); + int second = doSyncResourcesWithRetry(); + return first + second; + } + + synchronized int doSyncResourcesWithRetry() { + // retry runs MAX_RETRIES times + // and if errors result every time, it will fail + Retrier syncResourceRetrier = new Retrier<>(this::doSyncResources, MAX_RETRIES); + return syncResourceRetrier.runWithRetry(); + } + + private int doSyncResources() { + if (isStopping()) { + return 0; + } + + synchronized (mySyncResourcesLock) { + ourLog.debug("Starting sync {}s", myResourceName); + + IBundleProvider resourceBundleList = getResourceDao().search(mySearchParameterMap, mySystemRequestDetails); + + Integer resourceCount = resourceBundleList.size(); + assert resourceCount != null; + if (resourceCount >= SubscriptionConstants.MAX_SUBSCRIPTION_RESULTS) { + ourLog.error("Currently over {} {}s. Some {}s have not been loaded.", SubscriptionConstants.MAX_SUBSCRIPTION_RESULTS, myResourceName, myResourceName); + } + + List resourceList = resourceBundleList.getResources(0, resourceCount); + + return syncResourcesIntoCache(resourceList); + } + } + + protected abstract int syncResourcesIntoCache(List resourceList); + + @EventListener(ContextRefreshedEvent.class) + public void start() { + myStopping = false; + } + + @EventListener(ContextClosedEvent.class) + public void shutdown() { + myStopping = true; + } + + private boolean isStopping() { + return myStopping; + } + + private IFhirResourceDao getResourceDao() { + return myDaoRegistry.getResourceDao(myResourceName); + } + + + + + + @Override + public void handleInit(Collection theResourceIds) { + if (!myEnabled) { + return; + } + + if (!resourceDaoExists()) { + ourLog.warn("The resource type {} is enabled on this server, but there is no {} DAO configured.", myResourceName, myResourceName); + return; + } + IFhirResourceDao resourceDao = getResourceDao(); + SystemRequestDetails systemRequestDetails = SystemRequestDetails.forAllPartitions(); + List resourceList = theResourceIds.stream().map(n -> resourceDao.read(n, systemRequestDetails)).collect(Collectors.toList()); + handleInit(resourceList); + } + + protected abstract void handleInit(List resourceList); + + @Override + public void handleChange(IResourceChangeEvent theResourceChangeEvent) { + if (!myEnabled) { + return; + } + + // For now ignore the contents of theResourceChangeEvent. In the future, consider updating the registry based on + // known resources that have been created, updated & deleted + syncDatabaseToCache(); + } + + @Nonnull + protected abstract SearchParameterMap getSearchParameterMap(); +} diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/matching/SubscriptionMatchingStrategy.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/matching/SubscriptionMatchingStrategy.java index 98fe5bf164a..b28d538825d 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/matching/SubscriptionMatchingStrategy.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/matcher/matching/SubscriptionMatchingStrategy.java @@ -25,9 +25,14 @@ public enum SubscriptionMatchingStrategy { */ IN_MEMORY, - /** + /** * Resources cannot be matched against this subscription in-memory. We need to make a call to a FHIR Repository to determine a match */ - DATABASE + DATABASE, + + /** + * This subscription uses a SubscriptionTopic for its matching + */ + TOPIC } diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionCanonicalizer.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionCanonicalizer.java index 33fa3ee8b8e..245b4f5518e 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionCanonicalizer.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionCanonicalizer.java @@ -78,6 +78,8 @@ public class SubscriptionCanonicalizer { return canonicalizeDstu3(theSubscription); case R4: return canonicalizeR4(theSubscription); + case R4B: + return canonicalizeR4B(theSubscription); case R5: return canonicalizeR5(theSubscription); case DSTU2_HL7ORG: @@ -231,11 +233,20 @@ public class SubscriptionCanonicalizer { }, toList()))); } case R5: { - org.hl7.fhir.r5.model.Subscription subscription = (org.hl7.fhir.r5.model.Subscription) theSubscription; - return subscription - .getExtension() - .stream() - .collect(Collectors.groupingBy(t -> t.getUrl(), mapping(t -> t.getValueAsPrimitive().getValueAsString(), toList()))); + // TODO KHS fix org.hl7.fhir.r4b.model.BaseResource.getStructureFhirVersionEnum() for R4B + if (theSubscription instanceof org.hl7.fhir.r4b.model.Subscription) { + org.hl7.fhir.r4b.model.Subscription subscription = (org.hl7.fhir.r4b.model.Subscription) theSubscription; + return subscription + .getExtension() + .stream() + .collect(Collectors.groupingBy(t -> t.getUrl(), mapping(t -> t.getValueAsPrimitive().getValueAsString(), toList()))); + } else if (theSubscription instanceof org.hl7.fhir.r5.model.Subscription) { + org.hl7.fhir.r5.model.Subscription subscription = (org.hl7.fhir.r5.model.Subscription) theSubscription; + return subscription + .getExtension() + .stream() + .collect(Collectors.groupingBy(t -> t.getUrl(), mapping(t -> t.getValueAsPrimitive().getValueAsString(), toList()))); + } } case DSTU2_HL7ORG: case DSTU2_1: @@ -307,25 +318,32 @@ public class SubscriptionCanonicalizer { return retVal; } - private CanonicalSubscription canonicalizeR5(IBaseResource theSubscription) { - org.hl7.fhir.r5.model.Subscription subscription = (org.hl7.fhir.r5.model.Subscription) theSubscription; + private CanonicalSubscription canonicalizeR4B(IBaseResource theSubscription) { + org.hl7.fhir.r4b.model.Subscription subscription = (org.hl7.fhir.r4b.model.Subscription) theSubscription; CanonicalSubscription retVal = new CanonicalSubscription(); - Enumerations.SubscriptionStatusCodes status = subscription.getStatus(); + org.hl7.fhir.r4b.model.Enumerations.SubscriptionStatus status = subscription.getStatus(); if (status != null) { retVal.setStatus(org.hl7.fhir.r4.model.Subscription.SubscriptionStatus.fromCode(status.toCode())); } setPartitionIdOnReturnValue(theSubscription, retVal); retVal.setChannelType(getChannelType(subscription)); retVal.setCriteriaString(getCriteria(theSubscription)); - retVal.setEndpointUrl(subscription.getEndpoint()); - retVal.setHeaders(subscription.getHeader()); + retVal.setEndpointUrl(subscription.getChannel().getEndpoint()); + retVal.setHeaders(subscription.getChannel().getHeader()); retVal.setChannelExtensions(extractExtension(subscription)); retVal.setIdElement(subscription.getIdElement()); - retVal.setPayloadString(subscription.getContentType()); + retVal.setPayloadString(subscription.getChannel().getPayload()); retVal.setPayloadSearchCriteria(getExtensionString(subscription, HapiExtensions.EXT_SUBSCRIPTION_PAYLOAD_SEARCH_CRITERIA)); retVal.setTags(extractTags(subscription)); + List profiles = subscription.getMeta().getProfile(); + for (org.hl7.fhir.r4b.model.CanonicalType next : profiles) { + if (SubscriptionConstants.SUBSCRIPTION_TOPIC_PROFILE_URL.equals(next.getValueAsString())) { + retVal.setTopicSubscription(true); + } + } + if (retVal.getChannelType() == CanonicalSubscriptionChannelType.EMAIL) { String from; String subjectTemplate; @@ -339,6 +357,78 @@ public class SubscriptionCanonicalizer { retVal.getEmailDetails().setSubjectTemplate(subjectTemplate); } + if (retVal.getChannelType() == CanonicalSubscriptionChannelType.RESTHOOK) { + String stripVersionIds; + String deliverLatestVersion; + try { + stripVersionIds = getExtensionString(subscription.getChannel(), HapiExtensions.EXT_SUBSCRIPTION_RESTHOOK_STRIP_VERSION_IDS); + deliverLatestVersion = getExtensionString(subscription.getChannel(), HapiExtensions.EXT_SUBSCRIPTION_RESTHOOK_DELIVER_LATEST_VERSION); + } catch (FHIRException theE) { + throw new ConfigurationException(Msg.code(565) + "Failed to extract subscription extension(s): " + theE.getMessage(), theE); + } + retVal.getRestHookDetails().setStripVersionId(Boolean.parseBoolean(stripVersionIds)); + retVal.getRestHookDetails().setDeliverLatestVersion(Boolean.parseBoolean(deliverLatestVersion)); + } + + List topicExts = subscription.getExtensionsByUrl("http://hl7.org/fhir/subscription/topics"); + if (topicExts.size() > 0) { + IBaseReference ref = (IBaseReference) topicExts.get(0).getValueAsPrimitive(); + if (!"EventDefinition".equals(ref.getReferenceElement().getResourceType())) { + throw new PreconditionFailedException(Msg.code(566) + "Topic reference must be an EventDefinition"); + } + } + + org.hl7.fhir.r4b.model.Extension extension = subscription.getChannel().getExtensionByUrl(EX_SEND_DELETE_MESSAGES); + if (extension != null && extension.hasValue() && extension.hasValueBooleanType()) { + retVal.setSendDeleteMessages(extension.getValueBooleanType().booleanValue()); + } + + return retVal; + } + + private CanonicalSubscription canonicalizeR5(IBaseResource theSubscription) { + org.hl7.fhir.r5.model.Subscription subscription = (org.hl7.fhir.r5.model.Subscription) theSubscription; + + // WIP STR5 now that we have SubscriptionTopic, rewrite this so that all R5 subscriptions are SubscriptionTopic + // subscriptions. This will require major rework of RestHookTestR5Test + + CanonicalSubscription retVal = new CanonicalSubscription(); + Enumerations.SubscriptionStatusCodes status = subscription.getStatus(); + if (status != null) { + retVal.setStatus(org.hl7.fhir.r4.model.Subscription.SubscriptionStatus.fromCode(status.toCode())); + } + setPartitionIdOnReturnValue(theSubscription, retVal); + retVal.setChannelType(getChannelType(subscription)); + retVal.setCriteriaString(getCriteria(theSubscription)); + retVal.setEndpointUrl(subscription.getEndpoint()); + retVal.setHeaders(subscription.getHeader()); + retVal.setChannelExtensions(extractExtension(subscription)); + retVal.setIdElement(subscription.getIdElement()); + retVal.setPayloadString(subscription.getContentType()); + retVal.setPayloadSearchCriteria(getExtensionString(subscription, HapiExtensions.EXT_SUBSCRIPTION_PAYLOAD_SEARCH_CRITERIA)); + retVal.setTags(extractTags(subscription)); + + + List profiles = subscription.getMeta().getProfile(); + for (org.hl7.fhir.r5.model.CanonicalType next : profiles) { + if (SubscriptionConstants.SUBSCRIPTION_TOPIC_PROFILE_URL.equals(next.getValueAsString())) { + retVal.setTopicSubscription(true); + } + } + + if (retVal.getChannelType() == CanonicalSubscriptionChannelType.EMAIL) { + String from; + String subjectTemplate; + try { + from = getExtensionString(subscription, HapiExtensions.EXT_SUBSCRIPTION_EMAIL_FROM); + subjectTemplate = getExtensionString(subscription, HapiExtensions.EXT_SUBSCRIPTION_SUBJECT_TEMPLATE); + } catch (FHIRException theE) { + throw new ConfigurationException(Msg.code(2323) + "Failed to extract subscription extension(s): " + theE.getMessage(), theE); + } + retVal.getEmailDetails().setFrom(from); + retVal.getEmailDetails().setSubjectTemplate(subjectTemplate); + } + if (retVal.getChannelType() == CanonicalSubscriptionChannelType.RESTHOOK) { String stripVersionIds; String deliverLatestVersion; @@ -346,7 +436,7 @@ public class SubscriptionCanonicalizer { stripVersionIds = getExtensionString(subscription, HapiExtensions.EXT_SUBSCRIPTION_RESTHOOK_STRIP_VERSION_IDS); deliverLatestVersion = getExtensionString(subscription, HapiExtensions.EXT_SUBSCRIPTION_RESTHOOK_DELIVER_LATEST_VERSION); } catch (FHIRException theE) { - throw new ConfigurationException(Msg.code(565) + "Failed to extract subscription extension(s): " + theE.getMessage(), theE); + throw new ConfigurationException(Msg.code(2324) + "Failed to extract subscription extension(s): " + theE.getMessage(), theE); } retVal.getRestHookDetails().setStripVersionId(Boolean.parseBoolean(stripVersionIds)); retVal.getRestHookDetails().setDeliverLatestVersion(Boolean.parseBoolean(deliverLatestVersion)); @@ -356,7 +446,7 @@ public class SubscriptionCanonicalizer { if (topicExts.size() > 0) { IBaseReference ref = (IBaseReference) topicExts.get(0).getValueAsPrimitive(); if (!"EventDefinition".equals(ref.getReferenceElement().getResourceType())) { - throw new PreconditionFailedException(Msg.code(566) + "Topic reference must be an EventDefinition"); + throw new PreconditionFailedException(Msg.code(2325) + "Topic reference must be an EventDefinition"); } } @@ -408,6 +498,14 @@ public class SubscriptionCanonicalizer { } break; } + case R4B: { + org.hl7.fhir.r4b.model.Subscription.SubscriptionChannelType type = ((org.hl7.fhir.r4b.model.Subscription) theSubscription).getChannel().getType(); + if (type != null) { + String channelTypeCode = type.toCode(); + retVal = CanonicalSubscriptionChannelType.fromCode(null, channelTypeCode); + } + break; + } case R5: { org.hl7.fhir.r5.model.Coding nextTypeCode = ((org.hl7.fhir.r5.model.Subscription) theSubscription).getChannelType(); CanonicalSubscriptionChannelType code = CanonicalSubscriptionChannelType.fromCode(nextTypeCode.getSystem(), nextTypeCode.getCode()); @@ -416,6 +514,8 @@ public class SubscriptionCanonicalizer { } break; } + default: + throw new IllegalStateException(Msg.code(2326) + "Unsupported Subscription FHIR version: " + myFhirContext.getVersion().getVersion()); } return retVal; @@ -436,6 +536,9 @@ public class SubscriptionCanonicalizer { case R4: retVal = ((org.hl7.fhir.r4.model.Subscription) theSubscription).getCriteria(); break; + case R4B: + retVal = ((org.hl7.fhir.r4b.model.Subscription) theSubscription).getCriteria(); + break; case R5: org.hl7.fhir.r5.model.Subscription subscription = (org.hl7.fhir.r5.model.Subscription) theSubscription; String topicElement = subscription.getTopicElement().getValue(); @@ -446,13 +549,16 @@ public class SubscriptionCanonicalizer { } retVal = topic.getResourceTriggerFirstRep().getQueryCriteria().getCurrent(); break; + default: + throw new IllegalStateException(Msg.code(2327) + "Subscription criteria is not supported for FHIR version: " + myFhirContext.getVersion().getVersion()); } return retVal; } - public void setMatchingStrategyTag(@Nonnull IBaseResource theSubscription, @Nullable SubscriptionMatchingStrategy theStrategy) { + public void setMatchingStrategyTag(@Nonnull IBaseResource theSubscription, @Nullable SubscriptionMatchingStrategy + theStrategy) { IBaseMetaType meta = theSubscription.getMeta(); // Remove any existing strategy tag @@ -477,6 +583,8 @@ public class SubscriptionCanonicalizer { display = "Database"; } else if (theStrategy == SubscriptionMatchingStrategy.IN_MEMORY) { display = "In-memory"; + } else if (theStrategy == SubscriptionMatchingStrategy.TOPIC) { + display = "SubscriptionTopic"; } else { throw new IllegalStateException(Msg.code(567) + "Unknown " + SubscriptionMatchingStrategy.class.getSimpleName() + ": " + theStrategy); } diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionConstants.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionConstants.java index 34499ddec41..8b0a4c95275 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionConstants.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/match/registry/SubscriptionConstants.java @@ -46,4 +46,5 @@ public class SubscriptionConstants { public static final String REQUESTED_STATUS = Subscription.SubscriptionStatus.REQUESTED.toCode(); public static final String ACTIVE_STATUS = Subscription.SubscriptionStatus.ACTIVE.toCode(); public static final String ERROR_STATUS = Subscription.SubscriptionStatus.ERROR.toCode(); + public static final String SUBSCRIPTION_TOPIC_PROFILE_URL = "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-subscription"; } diff --git a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/model/CanonicalSubscription.java b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/model/CanonicalSubscription.java index bcbd49a7f66..4b4d2f9d360 100644 --- a/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/model/CanonicalSubscription.java +++ b/hapi-fhir-storage/src/main/java/ca/uhn/fhir/jpa/subscription/model/CanonicalSubscription.java @@ -77,6 +77,8 @@ public class CanonicalSubscription implements Serializable, Cloneable, IModelJso private boolean myCrossPartitionEnabled; @JsonProperty("sendDeleteMessages") private boolean mySendDeleteMessages; + private boolean myIsTopicSubscription; + /** * Constructor */ @@ -351,6 +353,14 @@ public class CanonicalSubscription implements Serializable, Cloneable, IModelJso .toString(); } + public void setTopicSubscription(boolean theTopicSubscription) { + myIsTopicSubscription = theTopicSubscription; + } + + public boolean isTopicSubscription() { + return myIsTopicSubscription; + } + public static class EmailDetails implements IModelJson { @JsonProperty("from")