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:
+ *
+ * - ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage - Hooks may modify this parameter. This will affect the checking process.
+ *
+ *
+ *
+ * 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:
+ *
+ * - ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage - This parameter should not be modified as processing is complete when this hook is invoked.
+ *
+ *
+ *
+ * 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")