From b02088a5057d3bc158846c1b60784aa2aa5db9dc Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 11:07:36 +0000 Subject: [PATCH 01/33] DOC-428: readme instructions --- activity-feed-adapter/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 activity-feed-adapter/README.md diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md new file mode 100644 index 0000000..98be259 --- /dev/null +++ b/activity-feed-adapter/README.md @@ -0,0 +1 @@ +# Activity feed adapter example From eb86f5a7ca6677ac00c9fe8a7521fda7610a6b86 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 14:19:08 +0000 Subject: [PATCH 02/33] DOC-428: code --- activity-feed-adapter/pom.xml | 128 +++++++++++ .../feed/ActivityFeedGatewayApplication.java | 127 +++++++++++ ...eedListenerStreamingSourceHandlerImpl.java | 131 +++++++++++ ...yFeedSnapshotPollingSourceHandlerImpl.java | 121 ++++++++++ .../gateway/example/activity/feed/Runner.java | 22 ++ .../common/jackson/ObjectMapperUtils.java | 17 ++ .../feed/client/ActivityFeedClient.java | 13 ++ .../feed/client/ActivityFeedListener.java | 7 + .../client/impl/ActivityFeedClientImpl.java | 51 +++++ .../example/activity/feed/model/Activity.java | 44 ++++ .../feed/service/ActivityFeedServer.java | 14 ++ .../impl/ActivityGeneratorSupplier.java | 61 +++++ .../impl/PretendActivityFeedServerImpl.java | 192 ++++++++++++++++ .../src/main/resources/configuration.json | 49 ++++ .../src/main/resources/log4j2.xml | 27 +++ .../ActivityFeedGatewayApplicationTest.java | 184 +++++++++++++++ ...istenerStreamingSourceHandlerImplTest.java | 208 +++++++++++++++++ ...dSnapshotPollingSourceHandlerImplTest.java | 215 ++++++++++++++++++ .../activity/feed/ActivityJsonTest.java | 25 ++ .../example/activity/feed/RunnerTest.java | 18 ++ .../common/jackson/ObjectMapperUtilsTest.java | 23 ++ .../impl/ActivityFeedClientImplTest.java | 81 +++++++ .../feed/model/ActivityTestUtils.java | 17 ++ .../impl/ActivityGeneratorSupplierTest.java | 42 ++++ .../PretendActivityFeedServerImplTest.java | 192 ++++++++++++++++ 25 files changed, 2009 insertions(+) create mode 100644 activity-feed-adapter/pom.xml create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/Runner.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtils.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedClient.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedListener.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImpl.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/model/Activity.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/ActivityFeedServer.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplier.java create mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImpl.java create mode 100644 activity-feed-adapter/src/main/resources/configuration.json create mode 100644 activity-feed-adapter/src/main/resources/log4j2.xml create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplicationTest.java create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImplTest.java create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityJsonTest.java create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/RunnerTest.java create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtilsTest.java create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImplTest.java create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplierTest.java create mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImplTest.java diff --git a/activity-feed-adapter/pom.xml b/activity-feed-adapter/pom.xml new file mode 100644 index 0000000..9ddee83 --- /dev/null +++ b/activity-feed-adapter/pom.xml @@ -0,0 +1,128 @@ + + + + 4.0.0 + + com.diffusiondata.gateway.examples + activity-feed-adapter + 1.0.0-SNAPSHOT + + + UTF-8 + 11 + + 2.2.0 + 2.16.1 + 1.9.0 + 5.11.3 + 3.0 + 5.11.0 + + 3.13.0 + 3.4.2 + 3.5.2 + 3.7.1 + + + + + com.diffusiondata.gateway + gateway-framework + ${gateway-framework.version} + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson-datatype-jsr310.version} + + + + net.datafaker + datafaker + ${datafaker.version} + + + + org.junit.jupiter + junit-jupiter-api + ${junit-jupiter-api.version} + test + + + + org.hamcrest + hamcrest + ${hamcrest.version} + test + + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.apache.maven.plugins + maven-assembly-plugin + ${maven-assembly-plugin.version} + + + + jar-with-dependencies + + + + + com.diffusiondata.gateway.example.activity.feed.Runner + + + + + + make-assembly + package + + single + + + + + + + + + + push-repository + https://download.diffusiondata.com/maven/ + + + diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java new file mode 100644 index 0000000..83d9111 --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java @@ -0,0 +1,127 @@ +package com.diffusiondata.gateway.example.activity.feed; + +import static java.util.Objects.requireNonNull; + +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.diffusiondata.gateway.framework.DiffusionGatewayFramework; +import com.diffusiondata.gateway.framework.GatewayApplication; +import com.diffusiondata.gateway.framework.PollingSourceHandler; +import com.diffusiondata.gateway.framework.Publisher; +import com.diffusiondata.gateway.framework.ServiceDefinition; +import com.diffusiondata.gateway.framework.ServiceMode; +import com.diffusiondata.gateway.framework.StateHandler; +import com.diffusiondata.gateway.framework.StreamingSourceHandler; +import com.diffusiondata.gateway.framework.exceptions.ApplicationConfigurationException; +import com.diffusiondata.gateway.framework.exceptions.InvalidConfigurationException; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.fasterxml.jackson.databind.ObjectMapper; + +import net.jcip.annotations.Immutable; + +@Immutable +public final class ActivityFeedGatewayApplication + implements GatewayApplication { + + static final String APPLICATION_TYPE = + "activity-feed-application"; + + static final String STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME = + "streaming-activity-feed-service"; + + static final String POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME = + "polling-activity-feed-service"; + + private static final Logger LOG = + LoggerFactory.getLogger(ActivityFeedGatewayApplication.class); + + private final ObjectMapper objectMapper; + + private final ActivityFeedClient activityFeedClient; + + public ActivityFeedGatewayApplication( + ActivityFeedClient activityFeedClient, + ObjectMapper objectMapper) { + + this.objectMapper = + requireNonNull(objectMapper, "objectMapper"); + + this.activityFeedClient = + requireNonNull(activityFeedClient, "activityFeedClient"); + } + + @Override + public ApplicationDetails getApplicationDetails() + throws ApplicationConfigurationException { + + return DiffusionGatewayFramework.newApplicationDetailsBuilder() + .addServiceType( + STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME, + ServiceMode.STREAMING_SOURCE, + "Streaming activity feed", + null) + .addServiceType( + POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME, + ServiceMode.POLLING_SOURCE, + "Polled activity feed snapshot", + null + ) + .build(APPLICATION_TYPE, 1); + } + + @Override + public StreamingSourceHandler addStreamingSource( + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler) + throws InvalidConfigurationException { + + final String serviceType = + serviceDefinition.getServiceType().getName(); + + if (STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { + return new ActivityFeedListenerStreamingSourceHandlerImpl( + activityFeedClient, + serviceDefinition, + publisher, + stateHandler, + objectMapper); + } + + throw new InvalidConfigurationException( + "Unknown service type: " + serviceType); + } + + @Override + public PollingSourceHandler addPollingSource( + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler) + throws InvalidConfigurationException { + + final String serviceType = + serviceDefinition.getServiceType().getName(); + + if (POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { + return new ActivityFeedSnapshotPollingSourceHandlerImpl( + activityFeedClient, + serviceDefinition, + publisher, + stateHandler, + objectMapper); + } + + throw new InvalidConfigurationException( + "Unknown service type: " + serviceType); + } + + @Override + public CompletableFuture stop() { + LOG.info("Application stop"); + + return CompletableFuture.completedFuture(null); + } +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java new file mode 100644 index 0000000..a9f5620 --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java @@ -0,0 +1,131 @@ +package com.diffusiondata.gateway.example.activity.feed; + +import static java.util.Objects.requireNonNull; + +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.diffusiondata.gateway.framework.Publisher; +import com.diffusiondata.gateway.framework.ServiceDefinition; +import com.diffusiondata.gateway.framework.ServiceState; +import com.diffusiondata.gateway.framework.StateHandler; +import com.diffusiondata.gateway.framework.StreamingSourceHandler; +import com.diffusiondata.gateway.framework.exceptions.PayloadConversionException; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; +import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import net.jcip.annotations.ThreadSafe; + +@ThreadSafe +public final class ActivityFeedListenerStreamingSourceHandlerImpl + implements ActivityFeedListener, + StreamingSourceHandler { + + static final String DEFAULT_STREAMING_TOPIC_PREFIX = + "streaming/activity/feed"; + + private static final Logger LOG = + LoggerFactory.getLogger(ActivityFeedListenerStreamingSourceHandlerImpl.class); + + private final ActivityFeedClient activityFeedClient; + private final Publisher publisher; + private final StateHandler stateHandler; + private final ObjectMapper objectMapper; + private final String topicPrefix; + + private String listenerIdentifier; + + public ActivityFeedListenerStreamingSourceHandlerImpl( + ActivityFeedClient activityFeedClient, + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler, + ObjectMapper objectMapper) { + + this.activityFeedClient = + requireNonNull(activityFeedClient, "activityFeedClient"); + + this.publisher = requireNonNull(publisher, "publisher"); + this.stateHandler = requireNonNull(stateHandler, "stateHandler"); + requireNonNull(serviceDefinition, "serviceDefinition"); + this.objectMapper = requireNonNull(objectMapper, "objectMapper"); + + topicPrefix = serviceDefinition.getParameters() + .getOrDefault("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX) + .toString(); + } + + @Override + public void onMessage(Activity activity) { + requireNonNull(activity, "activity"); + + if (stateHandler.getState().equals(ServiceState.ACTIVE)) { + try { + final String topicPath = topicPrefix + "/" + activity.getSport(); + final String value = objectMapper.writeValueAsString(activity); + + publisher.publish(topicPath, value) + .exceptionally(throwable -> { + LOG.error("Cannot publish to topic: '{}'", + topicPath, throwable); + + return null; + }); + } + catch (JsonProcessingException | + PayloadConversionException e) { + + LOG.error("Cannot publish", e); + } + } + } + + @Override + public CompletableFuture start() { + listenerIdentifier = + activityFeedClient.registerListener(this); + + LOG.info("Started activity feed streaming handler"); + + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture stop() { + activityFeedClient.unregisterListener(listenerIdentifier); + + LOG.info("Activity feed streaming handler stopped"); + + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture pause(PauseReason reason) { + LOG.info("Activity feed streaming handler paused"); + + //TODO: JH - could unregister for events here + + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture resume(ResumeReason reason) { + LOG.info("Activity feed streaming handler resumed"); + + //TODO: JH - could register for events here + + return CompletableFuture.completedFuture(null); + } + + /** + * package for tests. + */ + String getTopicPrefix() { + return topicPrefix; + } +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java new file mode 100644 index 0000000..5071518 --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java @@ -0,0 +1,121 @@ +package com.diffusiondata.gateway.example.activity.feed; + +import static java.util.Objects.requireNonNull; + +import java.util.Collection; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.diffusiondata.gateway.framework.PollingSourceHandler; +import com.diffusiondata.gateway.framework.Publisher; +import com.diffusiondata.gateway.framework.ServiceDefinition; +import com.diffusiondata.gateway.framework.ServiceState; +import com.diffusiondata.gateway.framework.StateHandler; +import com.diffusiondata.gateway.framework.exceptions.PayloadConversionException; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public final class ActivityFeedSnapshotPollingSourceHandlerImpl + implements PollingSourceHandler { + + static final String DEFAULT_POLLING_TOPIC_PATH = + "polling/activity/feed"; + + private static final Logger LOG = + LoggerFactory.getLogger(ActivityFeedSnapshotPollingSourceHandlerImpl.class); + + private final ActivityFeedClient activityFeedClient; + private final Publisher publisher; + private final StateHandler stateHandler; + private final ObjectMapper objectMapper; + private final String topicPath; + + public ActivityFeedSnapshotPollingSourceHandlerImpl( + ActivityFeedClient activityFeedClient, + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler, + ObjectMapper objectMapper) { + + this.activityFeedClient = + requireNonNull(activityFeedClient, "activityFeedClient"); + + this.publisher = requireNonNull(publisher, "publisher"); + this.stateHandler = requireNonNull(stateHandler, "stateHandler"); + requireNonNull(serviceDefinition, "serviceDefinition"); + this.objectMapper = requireNonNull(objectMapper, "objectMapper"); + + topicPath = serviceDefinition.getParameters() + .getOrDefault("topicPath", DEFAULT_POLLING_TOPIC_PATH) + .toString(); + } + + @Override + public CompletableFuture poll() { + final CompletableFuture pollCf = new CompletableFuture<>(); + + if (!stateHandler.getState().equals(ServiceState.ACTIVE)) { + pollCf.complete(null); + + return pollCf; + } + + final Collection activities = + activityFeedClient.getLatestActivities(); + + if (activities.isEmpty()) { + pollCf.complete(null); + + return pollCf; + } + + try { + final String value = objectMapper.writeValueAsString(activities); + + publisher.publish(topicPath, value) + .whenComplete((o, throwable) -> { + if (throwable != null) { + pollCf.completeExceptionally(throwable); + } + else { + pollCf.complete(null); + } + }); + } + catch (JsonProcessingException | + PayloadConversionException e) { + + LOG.error("Cannot publish", e); + pollCf.completeExceptionally(e); + + return pollCf; + } + + return pollCf; + } + + @Override + public CompletableFuture pause(PauseReason reason) { + LOG.info("Activity feed polling handler paused"); + + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture resume(ResumeReason reason) { + LOG.info("Activity feed polling handler resumed"); + + return CompletableFuture.completedFuture(null); + } + + /** + * package for tests. + */ + String getTopicPath() { + return topicPath; + } +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/Runner.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/Runner.java new file mode 100644 index 0000000..c368bde --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/Runner.java @@ -0,0 +1,22 @@ +package com.diffusiondata.gateway.example.activity.feed; + +import static com.diffusiondata.gateway.example.common.jackson.ObjectMapperUtils.createAndConfigureObjectMapper; + +import com.diffusiondata.gateway.framework.DiffusionGatewayFramework; +import com.diffusiondata.gateway.framework.GatewayApplication; +import com.diffusiondata.pretend.example.activity.feed.client.impl.ActivityFeedClientImpl; + +public final class Runner { + public static void main(String[] args) { + DiffusionGatewayFramework.start(createGatewayApplication()); + } + + /** + * package for tests. + */ + static GatewayApplication createGatewayApplication() { + return new ActivityFeedGatewayApplication( + ActivityFeedClientImpl.connectToActivityFeedServer(), + createAndConfigureObjectMapper()); + } +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtils.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtils.java new file mode 100644 index 0000000..7cc0448 --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtils.java @@ -0,0 +1,17 @@ +package com.diffusiondata.gateway.example.common.jackson; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +public final class ObjectMapperUtils { + private ObjectMapperUtils() { + // Private method to prevent creation + } + + public static ObjectMapper createAndConfigureObjectMapper() { + final ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + return objectMapper; + } +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedClient.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedClient.java new file mode 100644 index 0000000..ef780b8 --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedClient.java @@ -0,0 +1,13 @@ +package com.diffusiondata.pretend.example.activity.feed.client; + +import java.util.Collection; + +import com.diffusiondata.pretend.example.activity.feed.model.Activity; + +public interface ActivityFeedClient { + String registerListener(ActivityFeedListener activityFeedListener); + + boolean unregisterListener(String listenerIdentifier); + + Collection getLatestActivities(); +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedListener.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedListener.java new file mode 100644 index 0000000..42a682c --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedListener.java @@ -0,0 +1,7 @@ +package com.diffusiondata.pretend.example.activity.feed.client; + +import com.diffusiondata.pretend.example.activity.feed.model.Activity; + +public interface ActivityFeedListener { + void onMessage(Activity activity); +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImpl.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImpl.java new file mode 100644 index 0000000..e89d011 --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImpl.java @@ -0,0 +1,51 @@ +package com.diffusiondata.pretend.example.activity.feed.client.impl; + +import java.util.Collection; + +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; +import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.diffusiondata.pretend.example.activity.feed.service.ActivityFeedServer; +import com.diffusiondata.pretend.example.activity.feed.service.impl.PretendActivityFeedServerImpl; + +import net.jcip.annotations.Immutable; + +@Immutable +public final class ActivityFeedClientImpl + implements ActivityFeedClient { + + private final ActivityFeedServer activityFeedServer; + + private ActivityFeedClientImpl(ActivityFeedServer activityFeedServer) { + this.activityFeedServer = activityFeedServer; + } + + @Override + public String registerListener(ActivityFeedListener activityFeedListener) { + return activityFeedServer.registerClientListener(activityFeedListener); + } + + @Override + public boolean unregisterListener(String listenerIdentifier) { + return activityFeedServer.unregisterClientListener(listenerIdentifier); + } + + @Override + public Collection getLatestActivities() { + return activityFeedServer.getLatestActivities(); + } + + public static ActivityFeedClient connectToActivityFeedServer() { + return connectToActivityFeedServer(PretendActivityFeedServerImpl + .createAndStartActivityFeedServer()); + } + + /** + * package for tests. + */ + static ActivityFeedClient connectToActivityFeedServer( + ActivityFeedServer activityFeedServer) { + + return new ActivityFeedClientImpl(activityFeedServer); + } +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/model/Activity.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/model/Activity.java new file mode 100644 index 0000000..1eed683 --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/model/Activity.java @@ -0,0 +1,44 @@ +package com.diffusiondata.pretend.example.activity.feed.model; + +import java.time.Instant; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import net.jcip.annotations.Immutable; + +@Immutable +public class Activity { + private final String sport; + private final String country; + private final String winner; + private final Instant dateOfActivity; + + public Activity( + String sport, + String country, + String winner, + Instant dateOfActivity) { + + this.sport = sport; + this.country = country; + this.winner = winner; + this.dateOfActivity = dateOfActivity; + } + + public String getSport() { + return sport; + } + + public String getCountry() { + return country; + } + + public String getWinner() { + return winner; + } + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC") + public Instant getDateOfActivity() { + return dateOfActivity; + } +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/ActivityFeedServer.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/ActivityFeedServer.java new file mode 100644 index 0000000..932e24e --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/ActivityFeedServer.java @@ -0,0 +1,14 @@ +package com.diffusiondata.pretend.example.activity.feed.service; + +import java.util.Collection; + +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; +import com.diffusiondata.pretend.example.activity.feed.model.Activity; + +public interface ActivityFeedServer { + String registerClientListener(ActivityFeedListener activityFeedListener); + + boolean unregisterClientListener(String listenerIdentifier); + + Collection getLatestActivities(); +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplier.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplier.java new file mode 100644 index 0000000..05c4101 --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplier.java @@ -0,0 +1,61 @@ +package com.diffusiondata.pretend.example.activity.feed.service.impl; + +import static java.util.concurrent.TimeUnit.DAYS; + +import java.time.Instant; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import com.diffusiondata.pretend.example.activity.feed.model.Activity; + +import net.datafaker.Faker; +import net.jcip.annotations.ThreadSafe; + +@ThreadSafe +public final class ActivityGeneratorSupplier + implements Supplier { + + private final Faker faker; + + public ActivityGeneratorSupplier(Faker faker) { + this.faker = faker; + } + + @Override + public Activity get() { + final String sport = faker.olympicSport().summerOlympics(); + final String country = faker.country().name(); + final String winner = faker.name().fullName(); + final Instant dateOfActivity = timeAndDatePast(1, DAYS); + + return new Activity( + sport, + country, + winner, + dateOfActivity); + } + + /** + * package for tests. + */ + Instant timeAndDatePast( + long atMost, + TimeUnit timeUnit) { + + // Newer versions of Faker have timeAndDate().past(..). However, + // because we're using Java 11, the code from newer version of + // DataFaker has essentially been copied and put here to provide + // the same function. + final Instant aBitEarlierThanNow = + Instant.now().minusMillis(1); + + final long upperBoundMillis = timeUnit.toMillis(atMost); + final long aBitFurtherBack = + faker.random().nextLong(upperBoundMillis - 1); + + final long pastMillis = + (aBitEarlierThanNow.toEpochMilli() - 1) - aBitFurtherBack; + + return Instant.ofEpochMilli(pastMillis); + } +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImpl.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImpl.java new file mode 100644 index 0000000..3bc30f5 --- /dev/null +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImpl.java @@ -0,0 +1,192 @@ +package com.diffusiondata.pretend.example.activity.feed.service.impl; + +import static java.util.Collections.unmodifiableCollection; +import static java.util.Collections.unmodifiableMap; +import static java.util.Objects.requireNonNull; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Supplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; +import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.diffusiondata.pretend.example.activity.feed.service.ActivityFeedServer; + +import net.datafaker.Faker; +import net.jcip.annotations.GuardedBy; +import net.jcip.annotations.ThreadSafe; + +@ThreadSafe +public final class PretendActivityFeedServerImpl + implements ActivityFeedServer, Runnable { + + private static final Logger LOG = + LoggerFactory.getLogger(PretendActivityFeedServerImpl.class); + + private final Random random = new Random(); + + @GuardedBy("this") + private final Map listeners = + new HashMap<>(); + + private final ConcurrentMap cachedLatestActivities = + new ConcurrentHashMap<>(); + + private final Supplier activityGeneratorSupplier; + private final int maxSleepMillisBetweenActivityGeneration; + + private PretendActivityFeedServerImpl( + Supplier activityGeneratorSupplier, + int maxSleepMillisBetweenActivityGeneration) { + + this.activityGeneratorSupplier = activityGeneratorSupplier; + this.maxSleepMillisBetweenActivityGeneration = + Math.max(maxSleepMillisBetweenActivityGeneration, 1); + } + + @Override + public synchronized String registerClientListener( + ActivityFeedListener activityFeedListener) { + + requireNonNull(activityFeedListener, "activityFeedListener"); + + final String listenerIdentifier = UUID.randomUUID().toString(); + + listeners.put(listenerIdentifier, activityFeedListener); + + LOG.info("Registered client listener: '{}'", listenerIdentifier); + + return listenerIdentifier; + } + + @Override + public synchronized boolean unregisterClientListener( + String listenerIdentifier) { + + requireNonNull(listenerIdentifier, "listenerIdentifier"); + + if (!listeners.containsKey(listenerIdentifier)) { + LOG.warn("Cannot unregister listener with unknown identifier: '{}'", + listenerIdentifier); + + return false; + } + + listeners.remove(listenerIdentifier); + + LOG.info("Unregistered client listener: '{}'", listenerIdentifier); + + return true; + } + + @Override + public Collection getLatestActivities() { + return unmodifiableCollection(cachedLatestActivities.values()); + } + + @Override + public void run() { + LOG.info("Started activity feed server"); + + try { + while (!Thread.currentThread().isInterrupted()) { + runOnce(); + } + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * package for tests. + */ + Map getListeners() { + return unmodifiableMap(listeners); + } + + /** + * package for tests. + */ + void runOnce() + throws InterruptedException { + + final Activity activity = activityGeneratorSupplier.get(); + + internalUpdateStateAndListeners(activity); + + final int sleepDurationMillis = + random.nextInt(maxSleepMillisBetweenActivityGeneration); + + MILLISECONDS.sleep(sleepDurationMillis); + } + + /** + * package for tests. + */ + Map getCachedLatestActivities() { + return cachedLatestActivities; + } + + /** + * package for tests. + */ + void internalUpdateStateAndListeners(Activity activity) { + cachedLatestActivities.put(activity.getSport(), activity); + + listeners.values() + .forEach(listener -> { + try { + listener.onMessage(activity); + } + catch (Exception e) { + LOG.error("Exception invoking listener on message", e); + } + }); + } + + public static ActivityFeedServer createAndStartActivityFeedServer() { + final Supplier activityGeneratorSupplier = + new ActivityGeneratorSupplier(new Faker()); + + return createAndStartActivityFeedServer( + Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r); + t.setName("PretendActivityFeedServer-thread"); + t.setDaemon(true); + + return t; + }), + activityGeneratorSupplier, + 250); + } + + /** + * package for tests. + */ + static ActivityFeedServer createAndStartActivityFeedServer( + ExecutorService executorService, + Supplier activityGeneratorSupplier, + int maxSleepMillisBetweenActivityGeneration) { + + final PretendActivityFeedServerImpl server = + new PretendActivityFeedServerImpl( + activityGeneratorSupplier, + maxSleepMillisBetweenActivityGeneration); + + executorService.submit(server); + + return server; + } +} diff --git a/activity-feed-adapter/src/main/resources/configuration.json b/activity-feed-adapter/src/main/resources/configuration.json new file mode 100644 index 0000000..3468753 --- /dev/null +++ b/activity-feed-adapter/src/main/resources/configuration.json @@ -0,0 +1,49 @@ +{ + "id": "activity-feed-adapter-1", + "framework-version": 1, + "application-version": 1, + "diffusion": { + "url": "ws://localhost:18080", + "principal": "admin", + "password": "password", + "reconnectIntervalMs": 5000 + }, + "services": [ + { + "serviceName": "streaming-activity-feed-service-1", + "serviceType": "streaming-activity-feed-service", + "config": { + "framework": { + "topicProperties": { + "topicType": "JSON", + "persistencePolicy": "SESSION", + "publishValuesOnly": false, + "dontRetainValue": false + } + }, + "application": { + "topicPrefix": "activity/feed/stream" + } + } + }, + { + "serviceName": "polling-activity-feed-service-1", + "serviceType": "polling-activity-feed-service", + "config": { + "framework": { + "pollIntervalMs": 4500, + "pollTimeoutMs": 300000, + "topicProperties": { + "topicType": "JSON", + "persistencePolicy": "SESSION", + "publishValuesOnly": false, + "dontRetainValue": false + } + }, + "application": { + "topicPath": "activity/feed/snapshot" + } + } + } + ] +} diff --git a/activity-feed-adapter/src/main/resources/log4j2.xml b/activity-feed-adapter/src/main/resources/log4j2.xml new file mode 100644 index 0000000..458c02e --- /dev/null +++ b/activity-feed-adapter/src/main/resources/log4j2.xml @@ -0,0 +1,27 @@ + + + + + + %date{yyyy-MM-dd HH:mm:ss.SSS}|%level|%thread|%replace{%msg}{\|}{}|%logger%n%xEx + + + + + + + + + + + + + + + + + + diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplicationTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplicationTest.java new file mode 100644 index 0000000..a753a03 --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplicationTest.java @@ -0,0 +1,184 @@ +package com.diffusiondata.gateway.example.activity.feed; + +import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedGatewayApplication.APPLICATION_TYPE; +import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedGatewayApplication.POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME; +import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedGatewayApplication.STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME; +import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.hamcrest.core.IsInstanceOf.instanceOf; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.IsNull.nullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.AfterEach; +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 com.diffusiondata.gateway.framework.GatewayApplication; +import com.diffusiondata.gateway.framework.GatewayApplication.ApplicationDetails; +import com.diffusiondata.gateway.framework.PollingSourceHandler; +import com.diffusiondata.gateway.framework.Publisher; +import com.diffusiondata.gateway.framework.ServiceDefinition; +import com.diffusiondata.gateway.framework.ServiceType; +import com.diffusiondata.gateway.framework.StateHandler; +import com.diffusiondata.gateway.framework.StreamingSourceHandler; +import com.diffusiondata.gateway.framework.exceptions.InvalidConfigurationException; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class ActivityFeedGatewayApplicationTest { + @Mock + private ActivityFeedClient activityFeedClientMock; + + @Mock + private ObjectMapper objectMapperMock; + + @Mock + private ServiceDefinition serviceDefinitionMock; + + @Mock + private ServiceType serviceTypeMock; + + @Mock + private Publisher publisherMock; + + @Mock + private StateHandler stateHandlerMock; + + private GatewayApplication application; + + @BeforeEach + void beforeEachTest() { + application = new ActivityFeedGatewayApplication( + activityFeedClientMock, + objectMapperMock); + } + + @AfterEach + void afterEachTest() { + verifyNoMoreInteractions( + activityFeedClientMock, + objectMapperMock, + serviceDefinitionMock, + serviceTypeMock, + publisherMock, + stateHandlerMock + ); + } + + @Test + void testGetApplicationDetails() { + final ApplicationDetails applicationDetails = + application.getApplicationDetails(); + + assertThat(applicationDetails, notNullValue()); + + assertThat(applicationDetails.getApplicationType(), + equalTo(APPLICATION_TYPE)); + + final List serviceTypes = + applicationDetails.getServiceTypes(); + + assertThat(serviceTypes, iterableWithSize(2)); + } + + @Test + void testAddStreamingSourceWhenServiceTypeExists() + throws Exception { + + when(serviceDefinitionMock.getServiceType()) + .thenReturn(serviceTypeMock); + + when(serviceTypeMock.getName()) + .thenReturn(STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME); + + when(serviceDefinitionMock.getParameters()) + .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); + + final StreamingSourceHandler handler = + application.addStreamingSource( + serviceDefinitionMock, + publisherMock, + stateHandlerMock); + + assertThat(handler, notNullValue()); + assertThat(handler, + instanceOf(ActivityFeedListenerStreamingSourceHandlerImpl.class)); + } + + @Test + void testAddStreamingSourceWhenServiceTypeDoesNotExist() { + when(serviceDefinitionMock.getServiceType()) + .thenReturn(serviceTypeMock); + + when(serviceTypeMock.getName()) + .thenReturn("some-unknown-server-type"); + + assertThrows( + InvalidConfigurationException.class, + () -> application.addStreamingSource( + serviceDefinitionMock, + publisherMock, + stateHandlerMock)); + } + + @Test + void testAddPollingSourceWhenServiceTypeExists() + throws Exception { + + when(serviceDefinitionMock.getServiceType()) + .thenReturn(serviceTypeMock); + + when(serviceTypeMock.getName()) + .thenReturn(POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME); + + when(serviceDefinitionMock.getParameters()) + .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); + + final PollingSourceHandler handler = + application.addPollingSource( + serviceDefinitionMock, + publisherMock, + stateHandlerMock); + + assertThat(handler, notNullValue()); + assertThat(handler, + instanceOf(ActivityFeedSnapshotPollingSourceHandlerImpl.class)); + } + + @Test + void testAddPollingSourceWhenServiceTypeDoesNotExist() { + when(serviceDefinitionMock.getServiceType()) + .thenReturn(serviceTypeMock); + + when(serviceTypeMock.getName()) + .thenReturn("some-unknown-server-type"); + + assertThrows( + InvalidConfigurationException.class, + () -> application.addPollingSource( + serviceDefinitionMock, + publisherMock, + stateHandlerMock)); + } + + @Test + void testStop() { + final CompletableFuture cf = application.stop(); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + } +} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java new file mode 100644 index 0000000..3e4ef43 --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java @@ -0,0 +1,208 @@ +package com.diffusiondata.gateway.example.activity.feed; + +import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; +import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.IsNull.nullValue; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.diffusiondata.gateway.framework.Publisher; +import com.diffusiondata.gateway.framework.ServiceDefinition; +import com.diffusiondata.gateway.framework.ServiceHandler.PauseReason; +import com.diffusiondata.gateway.framework.ServiceHandler.ResumeReason; +import com.diffusiondata.gateway.framework.ServiceState; +import com.diffusiondata.gateway.framework.StateHandler; +import com.diffusiondata.gateway.framework.StreamingSourceHandler; +import com.diffusiondata.gateway.framework.exceptions.DiffusionClientException; +import com.diffusiondata.gateway.util.Util; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; +import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class ActivityFeedListenerStreamingSourceHandlerImplTest { + @Mock + private ActivityFeedClient activityFeedClientMock; + + @Mock + private ServiceDefinition serviceDefinitionMock; + + @Mock + Publisher publisherMock; + + @Mock + private StateHandler stateHandlerMock; + + @Mock + private ObjectMapper objectMapperMock; + + private StreamingSourceHandler handler; + + @BeforeEach + void beforeEachTest() { + when(serviceDefinitionMock.getParameters()) + .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); + + handler = new ActivityFeedListenerStreamingSourceHandlerImpl( + activityFeedClientMock, + serviceDefinitionMock, + publisherMock, + stateHandlerMock, + objectMapperMock); + + final String topicPrefix = + ((ActivityFeedListenerStreamingSourceHandlerImpl) handler) + .getTopicPrefix(); + + assertThat(topicPrefix, notNullValue()); + assertThat(topicPrefix, equalTo(DEFAULT_STREAMING_TOPIC_PREFIX)); + } + + @AfterEach + void afterEachTest() { + verifyNoMoreInteractions( + activityFeedClientMock, + serviceDefinitionMock, + publisherMock, + stateHandlerMock, + objectMapperMock + ); + } + + @Test + void testOnMessageWhenServiceStateIsActive() + throws Exception { + + final Activity activity = createPopulatedActivity(); + final String expectedTopicPath = DEFAULT_STREAMING_TOPIC_PREFIX + "/" + + activity.getSport(); + + final String jsonAsString = "{}"; + + when(stateHandlerMock.getState()) + .thenReturn(ServiceState.ACTIVE); + + when(objectMapperMock.writeValueAsString(activity)) + .thenReturn(jsonAsString); + + when(publisherMock.publish(expectedTopicPath, jsonAsString)) + .thenReturn(CompletableFuture.completedFuture(null)); + + ((ActivityFeedListener) handler).onMessage(activity); + } + + @Test + void testOnMessageWhenServiceStateIsActiveAndPublishExceptionIsThrown() + throws Exception { + + final Activity activity = createPopulatedActivity(); + final String topicPath = DEFAULT_STREAMING_TOPIC_PREFIX + "/" + + activity.getSport(); + + final String jsonAsString = "{}"; + + when(stateHandlerMock.getState()) + .thenReturn(ServiceState.ACTIVE); + + when(objectMapperMock.writeValueAsString(activity)) + .thenReturn(jsonAsString); + + when(publisherMock.publish(topicPath, jsonAsString)) + .thenReturn(Util.getCfWithException( + new DiffusionClientException("ignore this is a test"))); + + ((ActivityFeedListener) handler).onMessage(activity); + } + + @Test + void testOnMessageWhenServiceStateIsActiveAndCheckedExceptionIsThrown() + throws Exception { + + final Activity activity = createPopulatedActivity(); + + when(stateHandlerMock.getState()) + .thenReturn(ServiceState.ACTIVE); + + doThrow(JsonProcessingException.class).when(objectMapperMock) + .writeValueAsString(activity); + + ((ActivityFeedListener) handler).onMessage(activity); + } + + @Test + void testOnMessageWhenServiceStateIsNotActive() { + when(stateHandlerMock.getState()) + .thenReturn(ServiceState.PAUSED); + + ((ActivityFeedListener) handler).onMessage(createPopulatedActivity()); + } + + @Test + @Order(10) + void testStart() { + invokeStart(); + } + + @Test + @Order(20) + void testStop() { + final String listenerIdentifier = invokeStart(); + + when(activityFeedClientMock.unregisterListener(listenerIdentifier)) + .thenReturn(true); + + final CompletableFuture cf = handler.stop(); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + } + + @Test + void testPause() { + final CompletableFuture cf = handler.pause(PauseReason.REQUESTED); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + } + + @Test + void testResume() { + final CompletableFuture cf = handler.resume(ResumeReason.REQUESTED); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + } + + private String invokeStart() { + final String listenerIdentifier = "listener-identifier"; + + final ActivityFeedListener listener = (ActivityFeedListener) handler; + + when(activityFeedClientMock.registerListener(listener)) + .thenReturn(listenerIdentifier); + + final CompletableFuture cf = handler.start(); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + + return listenerIdentifier; + } +} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImplTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImplTest.java new file mode 100644 index 0000000..2fa69ee --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImplTest.java @@ -0,0 +1,215 @@ +package com.diffusiondata.gateway.example.activity.feed; + +import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedSnapshotPollingSourceHandlerImpl.DEFAULT_POLLING_TOPIC_PATH; +import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.hamcrest.core.IsInstanceOf.instanceOf; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.IsNull.nullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import org.junit.jupiter.api.AfterEach; +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 com.diffusiondata.gateway.framework.PollingSourceHandler; +import com.diffusiondata.gateway.framework.Publisher; +import com.diffusiondata.gateway.framework.ServiceDefinition; +import com.diffusiondata.gateway.framework.ServiceHandler.PauseReason; +import com.diffusiondata.gateway.framework.ServiceHandler.ResumeReason; +import com.diffusiondata.gateway.framework.ServiceState; +import com.diffusiondata.gateway.framework.StateHandler; +import com.diffusiondata.gateway.framework.exceptions.DiffusionClientException; +import com.diffusiondata.gateway.util.Util; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class ActivityFeedSnapshotPollingSourceHandlerImplTest { + @Mock + private ActivityFeedClient activityFeedClientMock; + + @Mock + private ServiceDefinition serviceDefinitionMock; + + @Mock + Publisher publisherMock; + + @Mock + private StateHandler stateHandlerMock; + + @Mock + private ObjectMapper objectMapperMock; + + private PollingSourceHandler handler; + + @BeforeEach + void beforeEachTest() { + when(serviceDefinitionMock.getParameters()) + .thenReturn(Map.of("topicPrefix", DEFAULT_POLLING_TOPIC_PATH)); + + handler = new ActivityFeedSnapshotPollingSourceHandlerImpl( + activityFeedClientMock, + serviceDefinitionMock, + publisherMock, + stateHandlerMock, + objectMapperMock); + + final String topicPrefix = + ((ActivityFeedSnapshotPollingSourceHandlerImpl) handler) + .getTopicPath(); + + assertThat(topicPrefix, notNullValue()); + assertThat(topicPrefix, equalTo(DEFAULT_POLLING_TOPIC_PATH)); + } + + @AfterEach + void afterEachTest() { + verifyNoMoreInteractions( + activityFeedClientMock, + serviceDefinitionMock, + publisherMock, + stateHandlerMock, + objectMapperMock + ); + } + + @Test + void testPollWhenServiceStateIsActiveAndLatestActivitiesIsEmpty() { + final Collection activities = List.of(); + + when(stateHandlerMock.getState()) + .thenReturn(ServiceState.ACTIVE); + + when(activityFeedClientMock.getLatestActivities()) + .thenReturn(activities); + + final CompletableFuture cf = handler.poll(); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + } + + @Test + void testPollWhenServiceStateIsActiveAndLatestActivitiesHasItems() + throws Exception { + + final Collection activities = + List.of(createPopulatedActivity()); + + final String jsonAsString = "{}"; + + when(stateHandlerMock.getState()) + .thenReturn(ServiceState.ACTIVE); + + when(activityFeedClientMock.getLatestActivities()) + .thenReturn(activities); + + when(objectMapperMock.writeValueAsString(activities)) + .thenReturn(jsonAsString); + + when(publisherMock.publish(DEFAULT_POLLING_TOPIC_PATH, jsonAsString)) + .thenReturn(CompletableFuture.completedFuture(null)); + + final CompletableFuture cf = handler.poll(); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + } + + @Test + void testPollWhenServiceStateIsActiveAndPublishExceptionIsThrown() + throws Exception { + + final Collection activities = + List.of(createPopulatedActivity()); + + final String jsonAsString = "{}"; + + when(stateHandlerMock.getState()) + .thenReturn(ServiceState.ACTIVE); + + when(activityFeedClientMock.getLatestActivities()) + .thenReturn(activities); + + when(objectMapperMock.writeValueAsString(activities)) + .thenReturn(jsonAsString); + + when(publisherMock.publish(DEFAULT_POLLING_TOPIC_PATH, jsonAsString)) + .thenReturn(Util.getCfWithException( + new DiffusionClientException("ignore this is a test"))); + + final CompletionException exception = + assertThrows(CompletionException.class, + () -> handler.poll().join()); + + assertThat(exception.getCause(), + instanceOf(DiffusionClientException.class)); + } + + @Test + void testPollWhenServiceStateIsActiveAndCheckedExceptionIsThrown() + throws Exception { + + final Collection activities = + List.of(createPopulatedActivity()); + + when(stateHandlerMock.getState()) + .thenReturn(ServiceState.ACTIVE); + + when(activityFeedClientMock.getLatestActivities()) + .thenReturn(activities); + + doThrow(JsonProcessingException.class).when(objectMapperMock) + .writeValueAsString(activities); + + final CompletionException exception = + assertThrows(CompletionException.class, + () -> handler.poll().join()); + + assertThat(exception.getCause(), + instanceOf(JsonProcessingException.class)); + } + + @Test + void testPollWhenServiceStateIsNotActive() { + when(stateHandlerMock.getState()) + .thenReturn(ServiceState.PAUSED); + + final CompletableFuture cf = handler.poll(); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + } + + @Test + void testPause() { + final CompletableFuture cf = handler.pause(PauseReason.REQUESTED); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + } + + @Test + void testResume() { + final CompletableFuture cf = handler.resume(ResumeReason.REQUESTED); + + assertThat(cf, notNullValue()); + assertThat(cf.join(), nullValue()); + } +} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityJsonTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityJsonTest.java new file mode 100644 index 0000000..24af116 --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityJsonTest.java @@ -0,0 +1,25 @@ +package com.diffusiondata.gateway.example.activity.feed; + +import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.StringContains.containsString; + +import org.junit.jupiter.api.Test; + +import com.diffusiondata.gateway.example.common.jackson.ObjectMapperUtils; +import com.fasterxml.jackson.databind.ObjectMapper; + +class ActivityJsonTest { + private final ObjectMapper objectMapper = + ObjectMapperUtils.createAndConfigureObjectMapper(); + + @Test + void testWriteValueAsString() + throws Exception { + + final String result = + objectMapper.writeValueAsString(createPopulatedActivity()); + + assertThat(result, containsString("dateOfActivity")); + } +} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/RunnerTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/RunnerTest.java new file mode 100644 index 0000000..142b649 --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/RunnerTest.java @@ -0,0 +1,18 @@ +package com.diffusiondata.gateway.example.activity.feed; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsNull.notNullValue; + +import org.junit.jupiter.api.Test; + +import com.diffusiondata.gateway.framework.GatewayApplication; + +class RunnerTest { + @Test + void testCreateGatewayApplication() { + final GatewayApplication gatewayApplication = + Runner.createGatewayApplication(); + + assertThat(gatewayApplication, notNullValue()); + } +} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtilsTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtilsTest.java new file mode 100644 index 0000000..bc3fe4a --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtilsTest.java @@ -0,0 +1,23 @@ +package com.diffusiondata.gateway.example.common.jackson; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize; +import static org.hamcrest.core.IsIterableContaining.hasItem; +import static org.hamcrest.core.IsNull.notNullValue; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +class ObjectMapperUtilsTest { + @Test + void testCreateAndConfigureObjectMapper() { + final ObjectMapper objectMapper = + ObjectMapperUtils.createAndConfigureObjectMapper(); + + assertThat(objectMapper, notNullValue()); + assertThat(objectMapper.getRegisteredModuleIds(), iterableWithSize(1)); + assertThat(objectMapper.getRegisteredModuleIds(), + hasItem("jackson-datatype-jsr310")); + } +} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImplTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImplTest.java new file mode 100644 index 0000000..81ea3b3 --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImplTest.java @@ -0,0 +1,81 @@ +package com.diffusiondata.pretend.example.activity.feed.client.impl; + +import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +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 com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; +import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.diffusiondata.pretend.example.activity.feed.service.ActivityFeedServer; + +@ExtendWith(MockitoExtension.class) +class ActivityFeedClientImplTest { + @Mock + private ActivityFeedServer activityFeedServerMock; + + private ActivityFeedClient activityFeedClient; + + @BeforeEach + void beforeEachTest() { + activityFeedClient = + ActivityFeedClientImpl.connectToActivityFeedServer(activityFeedServerMock); + } + + @AfterEach + void afterEachTest() { + verifyNoMoreInteractions(activityFeedServerMock); + } + + @Test + void testRegisterListener() { + final String expectedIdentifier = "listener-id-1"; + final ActivityFeedListener listener = mock(ActivityFeedListener.class); + + when(activityFeedServerMock.registerClientListener(listener)) + .thenReturn(expectedIdentifier); + + final String listenerIdentifier = + activityFeedClient.registerListener(listener); + + assertThat(listenerIdentifier, equalTo(expectedIdentifier)); + } + + @Test + void testUnregisterListener() { + when(activityFeedServerMock.unregisterClientListener("abc")) + .thenReturn(true); + + final boolean result = + activityFeedClient.unregisterListener("abc"); + + assertThat(result, equalTo(true)); + } + + @Test + void testGetActivityFeed() { + final Collection expectedActivities = + List.of(createPopulatedActivity()); + + when(activityFeedServerMock.getLatestActivities()) + .thenReturn(expectedActivities); + + final Collection latestActivities = + activityFeedClient.getLatestActivities(); + + assertThat(latestActivities, equalTo(expectedActivities)); + } +} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java new file mode 100644 index 0000000..d14aea4 --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java @@ -0,0 +1,17 @@ +package com.diffusiondata.pretend.example.activity.feed.model; + +import java.time.Instant; + +public final class ActivityTestUtils { + private ActivityTestUtils() { + // Private constructor to prevent creation + } + + public static Activity createPopulatedActivity() { + return createPopulatedActivity("s"); + } + + public static Activity createPopulatedActivity(String sport) { + return new Activity(sport, "c", "w", Instant.now()); + } +} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplierTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplierTest.java new file mode 100644 index 0000000..d69338c --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplierTest.java @@ -0,0 +1,42 @@ +package com.diffusiondata.pretend.example.activity.feed.service.impl; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.core.IsNull.notNullValue; + +import java.time.Instant; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; + +import com.diffusiondata.pretend.example.activity.feed.model.Activity; + +import net.datafaker.Faker; + +class ActivityGeneratorSupplierTest { + private final Supplier supplier = + new ActivityGeneratorSupplier(new Faker()); + + @Test + void testGet() { + final Activity activity = supplier.get(); + + assertThat(activity, notNullValue()); + assertThat(activity.getSport(), notNullValue()); + assertThat(activity.getCountry(), notNullValue()); + assertThat(activity.getWinner(), notNullValue()); + assertThat(activity.getDateOfActivity(), notNullValue()); + } + + @Test + void testTimeAndDatePast() { + final ActivityGeneratorSupplier impl = + (ActivityGeneratorSupplier) supplier; + + final Instant pastDate = impl.timeAndDatePast(2, MINUTES); + + assertThat(pastDate.toEpochMilli(), + lessThan(System.currentTimeMillis())); + } +} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImplTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImplTest.java new file mode 100644 index 0000000..6e2a46e --- /dev/null +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImplTest.java @@ -0,0 +1,192 @@ +package com.diffusiondata.pretend.example.activity.feed.service.impl; + +import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.collection.IsEmptyIterable.emptyIterable; +import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize; +import static org.hamcrest.collection.IsMapContaining.hasKey; +import static org.hamcrest.collection.IsMapWithSize.aMapWithSize; +import static org.hamcrest.collection.IsMapWithSize.anEmptyMap; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.IsSame.sameInstance; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; +import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.diffusiondata.pretend.example.activity.feed.service.ActivityFeedServer; + +@ExtendWith(MockitoExtension.class) +class PretendActivityFeedServerImplTest { + private static final String SPORT = "tennis"; + + @Mock + private ExecutorService executorServiceMock; + + @Mock + private ActivityGeneratorSupplier activityGeneratorSupplierMock; + + @Mock + private ActivityFeedListener activityFeedListenerMock; + + private ActivityFeedServer activityFeedServer; + + @BeforeEach + void beforeEachTest() { + when(executorServiceMock.submit(any(Runnable.class))) + .thenReturn(null); + + activityFeedServer = + PretendActivityFeedServerImpl.createAndStartActivityFeedServer( + executorServiceMock, + activityGeneratorSupplierMock, + 0); + } + + @AfterEach + void afterEachTest() { + verifyNoMoreInteractions( + executorServiceMock, + activityGeneratorSupplierMock, + activityFeedListenerMock + ); + } + + @Test + @Order(10) + void testRegisterClientListener() { + final String listenerIdentifier = + activityFeedServer.registerClientListener(activityFeedListenerMock); + + assertThat(listenerIdentifier, notNullValue()); + + final Map listeners = + getImpl().getListeners(); + + assertThat(listeners, aMapWithSize(1)); + assertThat(listeners, hasKey(listenerIdentifier)); + assertThat(listeners.get(listenerIdentifier), + sameInstance(activityFeedListenerMock)); + } + + @Test + @Order(20) + void testUnregisterClientListenerWhenExists() { + final String listenerIdentifier = + activityFeedServer.registerClientListener(activityFeedListenerMock); + + final boolean result = + activityFeedServer.unregisterClientListener(listenerIdentifier); + + assertThat(result, equalTo(true)); + + final Map listeners = + getImpl().getListeners(); + + assertThat(listeners, anEmptyMap()); + } + + @Test + void testUnregisterClientListenerWhenDoesNotExists() { + final boolean result = + activityFeedServer.unregisterClientListener( + "unknown-^^-listener-**-identifier"); + + assertThat(result, equalTo(false)); + } + + @Test + void testGetLatestActivitiesWhenNoneGenerated() { + final Collection latestActivities = + activityFeedServer.getLatestActivities(); + + assertThat(latestActivities, emptyIterable()); + } + + @Test + @Order(30) + void testInternalUpdateStateAndListenersWhenListenerRegistered() { + final Activity activity = createPopulatedActivity(SPORT); + + doNothing().when(activityFeedListenerMock) + .onMessage(activity); + + activityFeedServer.registerClientListener(activityFeedListenerMock); + + getImpl().internalUpdateStateAndListeners(activity); + + checkCachedLatestActivitiesAsExpected(); + } + + @Test + @Order(33) + void testInternalUpdateStateAndListenersWhenNoListenersRegistered() { + final Activity activity = createPopulatedActivity(SPORT); + + getImpl().internalUpdateStateAndListeners(activity); + + checkCachedLatestActivitiesAsExpected(); + } + + @Test + @Order(36) + void testInternalUpdateStateAndListenersWhenListenerRegisterAndExceptions() { + final Activity activity = createPopulatedActivity(SPORT); + + doThrow(IllegalStateException.class).when(activityFeedListenerMock) + .onMessage(activity); + + activityFeedServer.registerClientListener(activityFeedListenerMock); + + getImpl().internalUpdateStateAndListeners(activity); + + checkCachedLatestActivitiesAsExpected(); + } + + @Test + @Order(40) + void testGetLatestActivitiesWhenSomeGenerated() + throws Exception { + + final Activity activity = createPopulatedActivity(SPORT); + + when(activityGeneratorSupplierMock.get()) + .thenReturn(activity); + + getImpl().runOnce(); + + final Collection latestActivities = + activityFeedServer.getLatestActivities(); + + assertThat(latestActivities, iterableWithSize(1)); + assertThat(latestActivities.iterator().next(), equalTo(activity)); + } + + private PretendActivityFeedServerImpl getImpl() { + return (PretendActivityFeedServerImpl) activityFeedServer; + } + + private void checkCachedLatestActivitiesAsExpected() { + final Map cachedLatestActivities = + getImpl().getCachedLatestActivities(); + + assertThat(cachedLatestActivities, aMapWithSize(1)); + assertThat(cachedLatestActivities, hasKey(SPORT)); + } +} From 160b34d26e905a8a4c4b8c17379b1db24ff2351c Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 14:29:24 +0000 Subject: [PATCH 03/33] DOC-428: activity example --- activity-feed-adapter/README.md | 26 +++++++++++++++++++++++++- activity-feed-adapter/pom.xml | 13 ++++++------- pom.xml | 1 + 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md index 98be259..1956aa0 100644 --- a/activity-feed-adapter/README.md +++ b/activity-feed-adapter/README.md @@ -1 +1,25 @@ -# Activity feed adapter example +# Activity feed Gateway adapter example + +This project demonstrates the use of the Diffusion Gateway Framework. The +Gateway Framework provides an easy and consistent way to develop applications +that need to connect to a 'source' or 'sink' system and get data in and out +of Diffusion. + +Java 17+ is required to build and run this example. + +## How to build the project + + mvn clean install + + +## How to run the Activity feed Gateway adapter + + java -Dgateway.config.file=src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\target\activity-feed-adapter-1.0.0-SNAPSHOT-jar-with-dependencies.jar + + + +MORE INFORMATION TO FOLLOW...... + +NOTES: + + //TODO: JH - get parameters - for now, won't use a schema - but I do need to mention it. diff --git a/activity-feed-adapter/pom.xml b/activity-feed-adapter/pom.xml index 9ddee83..6e43c2e 100644 --- a/activity-feed-adapter/pom.xml +++ b/activity-feed-adapter/pom.xml @@ -5,9 +5,14 @@ 4.0.0 + + gateway-examples + com.diffusiondata.gateway.adapter + 1.0.0 + + com.diffusiondata.gateway.examples activity-feed-adapter - 1.0.0-SNAPSHOT UTF-8 @@ -27,12 +32,6 @@ - - com.diffusiondata.gateway - gateway-framework - ${gateway-framework.version} - - com.fasterxml.jackson.datatype jackson-datatype-jsr310 diff --git a/pom.xml b/pom.xml index 4194a47..e1377b0 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,7 @@ sample-diffusion-adapter human-diffusion-adapter languageConverter + activity-feed-adapter From 372c8e8110a6365952375398d3265bf28d037c1f Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 14:56:08 +0000 Subject: [PATCH 04/33] DOC-428: activity feed streaming and polling example. --- activity-feed-adapter/README.md | 30 ++++++++++++++++--- .../src/main/resources/configuration.json | 2 +- .../feed/model/ActivityTestUtils.java | 2 +- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md index 1956aa0..a72cb4e 100644 --- a/activity-feed-adapter/README.md +++ b/activity-feed-adapter/README.md @@ -5,8 +5,6 @@ Gateway Framework provides an easy and consistent way to develop applications that need to connect to a 'source' or 'sink' system and get data in and out of Diffusion. -Java 17+ is required to build and run this example. - ## How to build the project mvn clean install @@ -14,8 +12,7 @@ Java 17+ is required to build and run this example. ## How to run the Activity feed Gateway adapter - java -Dgateway.config.file=src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\target\activity-feed-adapter-1.0.0-SNAPSHOT-jar-with-dependencies.jar - + java -Dgateway.config.file=activity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\activity-feed-adapter\target\activity-feed-adapter-1.0.0-jar-with-dependencies.jar MORE INFORMATION TO FOLLOW...... @@ -23,3 +20,28 @@ MORE INFORMATION TO FOLLOW...... NOTES: //TODO: JH - get parameters - for now, won't use a schema - but I do need to mention it. + + +```mermaid +flowchart LR + +%% Nodes +AFS("Pretend \n Activity feed \n server"):::orange +AFG("Activity feed \n Gateway adapter"):::green +DIF("Diffusion server"):::blue + + +%% Edges + +AFS -. 1a) Send activity event .-> AFG +AFG -- 2a) Invoke get latest activities ---> AFS +AFS -- 2b) Return latest activities --> AFG + +AFG -- 1b) Update specific \n sport activity --> DIF +AFG -- 2c) Update the \n activities snapshot ---> DIF + +%% Styling +classDef green fill:#B2DFDB,stroke:#00897B,stroke-width:2px; +classDef orange fill:#FFE0B2,stroke:#FB8C00,stroke-width:2px; +classDef blue fill:#BBDEFB,stroke:#1976D2,stroke-width:2px; +``` \ No newline at end of file diff --git a/activity-feed-adapter/src/main/resources/configuration.json b/activity-feed-adapter/src/main/resources/configuration.json index 3468753..87e8f31 100644 --- a/activity-feed-adapter/src/main/resources/configuration.json +++ b/activity-feed-adapter/src/main/resources/configuration.json @@ -3,7 +3,7 @@ "framework-version": 1, "application-version": 1, "diffusion": { - "url": "ws://localhost:18080", + "url": "ws://localhost:8080", "principal": "admin", "password": "password", "reconnectIntervalMs": 5000 diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java index d14aea4..838047c 100644 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java @@ -8,7 +8,7 @@ private ActivityTestUtils() { } public static Activity createPopulatedActivity() { - return createPopulatedActivity("s"); + return createPopulatedActivity("some-sport"); } public static Activity createPopulatedActivity(String sport) { From 04b611268861f71640a069279d216f93362ccdae Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 15:05:34 +0000 Subject: [PATCH 05/33] DOC-428: activity feed streaming and polling example. --- activity-feed-adapter/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md index a72cb4e..cc9e512 100644 --- a/activity-feed-adapter/README.md +++ b/activity-feed-adapter/README.md @@ -26,7 +26,7 @@ NOTES: flowchart LR %% Nodes -AFS("Pretend \n Activity feed \n server"):::orange +AFS("Pretend
Activity feed \n server"):::orange AFG("Activity feed \n Gateway adapter"):::green DIF("Diffusion server"):::blue From 5d14f73b3a4393f59b841e29becae05f852c839a Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 15:06:49 +0000 Subject: [PATCH 06/33] DOC-428: activity feed streaming and polling example. --- activity-feed-adapter/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md index cc9e512..7e8788e 100644 --- a/activity-feed-adapter/README.md +++ b/activity-feed-adapter/README.md @@ -26,8 +26,8 @@ NOTES: flowchart LR %% Nodes -AFS("Pretend
Activity feed \n server"):::orange -AFG("Activity feed \n Gateway adapter"):::green +AFS("Pretend
Activity feed
server"):::orange +AFG("Activity feed
Gateway adapter"):::green DIF("Diffusion server"):::blue @@ -37,11 +37,11 @@ AFS -. 1a) Send activity event .-> AFG AFG -- 2a) Invoke get latest activities ---> AFS AFS -- 2b) Return latest activities --> AFG -AFG -- 1b) Update specific \n sport activity --> DIF -AFG -- 2c) Update the \n activities snapshot ---> DIF +AFG -- 1b) Update specific
sport activity --> DIF +AFG -- 2c) Update the
activities snapshot ---> DIF %% Styling classDef green fill:#B2DFDB,stroke:#00897B,stroke-width:2px; classDef orange fill:#FFE0B2,stroke:#FB8C00,stroke-width:2px; classDef blue fill:#BBDEFB,stroke:#1976D2,stroke-width:2px; -``` \ No newline at end of file +``` From 338d8d2a07015e1bab28f32a20e9c638cf0becf4 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 15:33:35 +0000 Subject: [PATCH 07/33] DOC-428: activity feed streaming and polling example. --- ...vityFeedListenerStreamingSourceHandlerImpl.java | 11 ++++++----- ...FeedListenerStreamingSourceHandlerImplTest.java | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java index a9f5620..e6014d7 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java @@ -99,25 +99,26 @@ public CompletableFuture start() { public CompletableFuture stop() { activityFeedClient.unregisterListener(listenerIdentifier); - LOG.info("Activity feed streaming handler stopped"); + LOG.info("Stopped activity feed streaming handler"); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture pause(PauseReason reason) { - LOG.info("Activity feed streaming handler paused"); + activityFeedClient.unregisterListener(listenerIdentifier); - //TODO: JH - could unregister for events here + LOG.info("Paused activity feed streaming handler"); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture resume(ResumeReason reason) { - LOG.info("Activity feed streaming handler resumed"); + listenerIdentifier = + activityFeedClient.registerListener(this); - //TODO: JH - could register for events here + LOG.info("Resumed activity feed streaming handler"); return CompletableFuture.completedFuture(null); } diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java index 3e4ef43..c47e4ea 100644 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java +++ b/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java @@ -175,7 +175,13 @@ void testStop() { } @Test + @Order(30) void testPause() { + final String listenerIdentifier = invokeStart(); + + when(activityFeedClientMock.unregisterListener(listenerIdentifier)) + .thenReturn(true); + final CompletableFuture cf = handler.pause(PauseReason.REQUESTED); assertThat(cf, notNullValue()); @@ -183,7 +189,15 @@ void testPause() { } @Test + @Order(40) void testResume() { + final String listenerIdentifier = "listener-identifier"; + + final ActivityFeedListener listener = (ActivityFeedListener) handler; + + when(activityFeedClientMock.registerListener(listener)) + .thenReturn(listenerIdentifier); + final CompletableFuture cf = handler.resume(ResumeReason.REQUESTED); assertThat(cf, notNullValue()); From 46a5a0ff346d0d19bd858700d2a65ea52ee4a7e8 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 15:45:34 +0000 Subject: [PATCH 08/33] DOC-428: activity feed streaming and polling example. --- activity-feed-adapter/pom.xml | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/activity-feed-adapter/pom.xml b/activity-feed-adapter/pom.xml index 6e43c2e..6305a3a 100644 --- a/activity-feed-adapter/pom.xml +++ b/activity-feed-adapter/pom.xml @@ -15,19 +15,12 @@ activity-feed-adapter - UTF-8 - 11 - - 2.2.0 2.16.1 1.9.0 5.11.3 3.0 5.11.0 - 3.13.0 - 3.4.2 - 3.5.2 3.7.1 @@ -74,21 +67,6 @@ - - org.apache.maven.plugins - maven-jar-plugin - ${maven-jar-plugin.version} - - - org.apache.maven.plugins - maven-surefire-plugin - ${maven-surefire-plugin.version} - - - org.apache.maven.plugins - maven-compiler-plugin - ${maven-compiler-plugin.version} - org.apache.maven.plugins maven-assembly-plugin @@ -117,11 +95,4 @@ - - - - push-repository - https://download.diffusiondata.com/maven/ - - From c14af6276ee083048daca9bc2af06648f92fdc4f Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 15:51:16 +0000 Subject: [PATCH 09/33] DOC-428: activity feed streaming and polling example. --- activity-feed-adapter/README.md | 7 ------- .../feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md index 7e8788e..fa67e65 100644 --- a/activity-feed-adapter/README.md +++ b/activity-feed-adapter/README.md @@ -15,13 +15,6 @@ of Diffusion. java -Dgateway.config.file=activity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\activity-feed-adapter\target\activity-feed-adapter-1.0.0-jar-with-dependencies.jar -MORE INFORMATION TO FOLLOW...... - -NOTES: - - //TODO: JH - get parameters - for now, won't use a schema - but I do need to mention it. - - ```mermaid flowchart LR diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java index 5071518..3d3dde5 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java @@ -100,14 +100,14 @@ public CompletableFuture poll() { @Override public CompletableFuture pause(PauseReason reason) { - LOG.info("Activity feed polling handler paused"); + LOG.info("Paused activity feed polling handler"); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture resume(ResumeReason reason) { - LOG.info("Activity feed polling handler resumed"); + LOG.info("Resumed activity feed polling handler"); return CompletableFuture.completedFuture(null); } From 0c3a2277b24000388814a5e71a5fe73364164191 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 18:16:50 +0000 Subject: [PATCH 10/33] DOC-428: activity feed streaming and polling example. --- activity-feed-adapter/README.md | 45 +++++++++++++++++++ ...yFeedSnapshotPollingSourceHandlerImpl.java | 3 ++ 2 files changed, 48 insertions(+) diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md index fa67e65..e3f7bc8 100644 --- a/activity-feed-adapter/README.md +++ b/activity-feed-adapter/README.md @@ -15,6 +15,37 @@ of Diffusion. java -Dgateway.config.file=activity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\activity-feed-adapter\target\activity-feed-adapter-1.0.0-jar-with-dependencies.jar +# Activity Feed Gateway Adapter Example +This article introduces the Diffusion Gateway Framework and an example Gateway Adapter that integrates with a pretend 'Activity' feed server. + +## Gateway Framework Overview +The Diffusion 'Gateway Framework' was written to make it easier to create adapters for getting data into Diffusion from source systems or publishing out of Diffusion into target/sink systems. Whilst it is feasible to develop everything using the standard Diffusion SDKs, the Gateway Framework transparently provides additional features that are frequently required: + +• Fault tolerance and failover +• A standard configuration file format with schema IDE support +• The ability to control the adapter, either programmatically, via REST API calls or through the Diffusion management console. +• Monitoring facilities, including integration with Prometheus, JMX and the Diffusion management console. + +## Activity Feed Example Overview +This example uses the concept of a sporting activity feed (think along the lines of popular exercise/social networks). Naturally, we don't build the platform for this tutorial; instead, we use a pretend activity feed server that generates realistic random sports activity data. + +The pretend activity feed server provides a client API that allows an application to subscribe to a feed of activities, with the changes pushed to the subscribed clients as they happen - so, this would be like someone completing an activity, uploading it and then the activity is sent as an event to subscribers. Additionally, the pretend activity feed client API has a mechanism for requesting a snapshot of the latest activities at a point in time. + +In this tutorial, we'll integrate the Gateway Framework with the pretend activity feed server and demonstrate data streaming into the Gateway adapter and polling to receive the activity snapshot. + +The activity domain object has the following attributes: +Sport: the sporting activity, such as swimming, sailing, tennis and other sports. +Country: the country where the sports activity took place. +Winner: the name of the person who won the sporting activity. +Date of activity: when the sporting activity took place. + +The pretend activity feed client has the following features: +Register a listener: an activity feed listener instance is required, with a callback method of 'onMessage' called when a new activity is sent to the subscriber (in our case, the Gateway adapter). +Unregister a listener: a way of unregistering from the activity feed to stop receiving updates. +Get latest activities: returns a snapshot list of the latest sporting activities when called. + +Below is a diagram depicting the overall example and the key components. + ```mermaid flowchart LR @@ -38,3 +69,17 @@ classDef green fill:#B2DFDB,stroke:#00897B,stroke-width:2px; classDef orange fill:#FFE0B2,stroke:#FB8C00,stroke-width:2px; classDef blue fill:#BBDEFB,stroke:#1976D2,stroke-width:2px; ``` + +Figure 1. + + +## Example Code and Configuration Walkthrough +The code for this example is within a Maven module of the Gateway Examples GitHub project: +https://github.com/diffusiondata/gateway-examples + +Follow the README file within the activity-feed-adapter module to start building the project and running the example. + +Developing the activity feed Gateway adapter requires very little code and just some configuration. Here's what we are going to create: +A class that implements the 'GatewayApplication' interface is the standard way of writing Gateway adapters. +A 'StreamingSourceHandler' will handle the activities sent from the pretend activity feed server and put the data into Diffusion topics. +A 'PollingSourceHandler' will periodically poll and request the activities snapshot from the pretend activity feed server. diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java index 3d3dde5..b2b07ca 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java @@ -19,6 +19,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import net.jcip.annotations.ThreadSafe; + +@ThreadSafe public final class ActivityFeedSnapshotPollingSourceHandlerImpl implements PollingSourceHandler { From 35f0f6d8fa3cdff4d0313c385ae4ef61815f3c12 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Wed, 20 Nov 2024 18:18:03 +0000 Subject: [PATCH 11/33] DOC-428: activity feed streaming and polling example. --- activity-feed-adapter/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md index e3f7bc8..22759c1 100644 --- a/activity-feed-adapter/README.md +++ b/activity-feed-adapter/README.md @@ -21,10 +21,10 @@ This article introduces the Diffusion Gateway Framework and an example Gateway A ## Gateway Framework Overview The Diffusion 'Gateway Framework' was written to make it easier to create adapters for getting data into Diffusion from source systems or publishing out of Diffusion into target/sink systems. Whilst it is feasible to develop everything using the standard Diffusion SDKs, the Gateway Framework transparently provides additional features that are frequently required: -• Fault tolerance and failover -• A standard configuration file format with schema IDE support -• The ability to control the adapter, either programmatically, via REST API calls or through the Diffusion management console. -• Monitoring facilities, including integration with Prometheus, JMX and the Diffusion management console. +- Fault tolerance and failover +- A standard configuration file format with schema IDE support +The ability to control the adapter, either programmatically, via REST API calls or through the Diffusion management console. +- Monitoring facilities, including integration with Prometheus, JMX and the Diffusion management console. ## Activity Feed Example Overview This example uses the concept of a sporting activity feed (think along the lines of popular exercise/social networks). Naturally, we don't build the platform for this tutorial; instead, we use a pretend activity feed server that generates realistic random sports activity data. From f1491d242f138550569662ab5a9e5668b3bff658 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Fri, 22 Nov 2024 11:06:15 +0000 Subject: [PATCH 12/33] DOC-428: activity feed streaming and polling example. --- activity-feed-adapter/README.md | 94 +++++++++++++------ .../feed/ActivityFeedGatewayApplication.java | 10 +- 2 files changed, 71 insertions(+), 33 deletions(-) diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md index 22759c1..4d3844d 100644 --- a/activity-feed-adapter/README.md +++ b/activity-feed-adapter/README.md @@ -16,35 +16,35 @@ of Diffusion. # Activity Feed Gateway Adapter Example -This article introduces the Diffusion Gateway Framework and an example Gateway Adapter that integrates with a pretend 'Activity' feed server. +## Introduction +In this tutorial, you will learn how to use the 'Diffusion Gateway Framework' to develop a Gateway adapter for feeding streaming and batch/polled data into your Diffusion server. The Gateway framework makes it easy to integrate with different datasources for getting data in and out of Diffusion. You will see how the Gateway Framework provides a common and consistent application structure and a higher level of abstraction over the Diffusion SDK, as well as handling things like retries and timeouts. -## Gateway Framework Overview -The Diffusion 'Gateway Framework' was written to make it easier to create adapters for getting data into Diffusion from source systems or publishing out of Diffusion into target/sink systems. Whilst it is feasible to develop everything using the standard Diffusion SDKs, the Gateway Framework transparently provides additional features that are frequently required: +After completing the tutorial, you can expect to understand how the Gateway framework helps to quickly develop adapters for getting data in and out of Diffusion. You can review and understand the solution code, learn how to run the example, and see the data updated in the Diffusion Console. -- Fault tolerance and failover -- A standard configuration file format with schema IDE support -The ability to control the adapter, either programmatically, via REST API calls or through the Diffusion management console. -- Monitoring facilities, including integration with Prometheus, JMX and the Diffusion management console. +## Overview +This example uses the concept of a sporting activity feed (think along the lines of popular exercise/social networks). Naturally, we don't build the platform for this tutorial; instead, we use a pretend activity feed server that generates realistic random sports activity data. -## Activity Feed Example Overview -This example uses the concept of a sporting activity feed (think along the lines of popular exercise/social networks). Naturally, we don't build the platform for this tutorial; instead, we use a pretend activity feed server that generates realistic random sports activity data. - -The pretend activity feed server provides a client API that allows an application to subscribe to a feed of activities, with the changes pushed to the subscribed clients as they happen - so, this would be like someone completing an activity, uploading it and then the activity is sent as an event to subscribers. Additionally, the pretend activity feed client API has a mechanism for requesting a snapshot of the latest activities at a point in time. +The pretend activity feed server provides a client API that allows an application to subscribe to a feed of activities, with the changes pushed to the subscribed clients as they happen - so, this would be like someone completing an activity, uploading it and then the activity is sent as an event to subscribers. Additionally, the pretend activity feed client API has a mechanism for requesting a snapshot of the latest activities at a point in time. In this tutorial, we'll integrate the Gateway Framework with the pretend activity feed server and demonstrate data streaming into the Gateway adapter and polling to receive the activity snapshot. +The final solution comprises the following: +- Pretend sports activity feed server - this provides a client API for receiving streaming events and the ability to request a snapshot of the latest activities. +- Gateway adapter - this is the application you will learn to build; it will integrate with the pretend sports activity feed server and put the data into your Diffusion server. +- Diffusion server - this is a running instance of Diffusion; you can run Diffusion in several different ways, such as running locally on your machine or connecting to a remote Diffusion server. + The activity domain object has the following attributes: -Sport: the sporting activity, such as swimming, sailing, tennis and other sports. -Country: the country where the sports activity took place. -Winner: the name of the person who won the sporting activity. -Date of activity: when the sporting activity took place. +- **Sport:** the sporting activity, such as swimming, sailing, tennis and other sports. +- **Country:** the country where the sports activity took place. +- **Winner:** the name of the person who won the sporting activity. +- **Date of activity:** when the sporting activity took place. The pretend activity feed client has the following features: -Register a listener: an activity feed listener instance is required, with a callback method of 'onMessage' called when a new activity is sent to the subscriber (in our case, the Gateway adapter). -Unregister a listener: a way of unregistering from the activity feed to stop receiving updates. -Get latest activities: returns a snapshot list of the latest sporting activities when called. +- **Register a listener:** an activity feed listener instance is required, with a callback method of 'onMessage' called when a new activity is sent to the subscriber (in our case, the Gateway adapter). +- **Unregister a listener:** a way of unregistering from the activity feed to stop receiving updates. +- **Get latest activities:** returns a snapshot list of the latest sporting activities when called. -Below is a diagram depicting the overall example and the key components. +Diagram of final solution: ```mermaid flowchart LR @@ -52,7 +52,7 @@ flowchart LR %% Nodes AFS("Pretend
Activity feed
server"):::orange AFG("Activity feed
Gateway adapter"):::green -DIF("Diffusion server"):::blue +DIF("Diffusion
server"):::blue %% Edges @@ -61,8 +61,8 @@ AFS -. 1a) Send activity event .-> AFG AFG -- 2a) Invoke get latest activities ---> AFS AFS -- 2b) Return latest activities --> AFG -AFG -- 1b) Update specific
sport activity --> DIF -AFG -- 2c) Update the
activities snapshot ---> DIF +AFG -- 1b) Update specific \n sport activity --> DIF +AFG -- 2c) Update the \n activities snapshot ---> DIF %% Styling classDef green fill:#B2DFDB,stroke:#00897B,stroke-width:2px; @@ -70,16 +70,48 @@ classDef orange fill:#FFE0B2,stroke:#FB8C00,stroke-width:2px; classDef blue fill:#BBDEFB,stroke:#1976D2,stroke-width:2px; ``` -Figure 1. - +## Prerequisites +To get started with the sports activity feed example, you will need the following: +- Java 11. +- Your preferred Java IDE. +- A running Diffusion server, this can be running locally or remotely; some of the options are: + - Install Diffusion via the standard Diffusion installer. + - Use the Diffusion Docker image to run a container. + - Use Diffusion Cloud, the DiffusionData SaaS offering. + - Connect to a Diffusion server running remotely. -## Example Code and Configuration Walkthrough -The code for this example is within a Maven module of the Gateway Examples GitHub project: -https://github.com/diffusiondata/gateway-examples +The activity feed example code is available on GitHub and is part of the overall Gateway examples project: +* [diffusiondata/gateway-examples](https://github.com/diffusiondata/gateway-examples) Follow the README file within the activity-feed-adapter module to start building the project and running the example. -Developing the activity feed Gateway adapter requires very little code and just some configuration. Here's what we are going to create: -A class that implements the 'GatewayApplication' interface is the standard way of writing Gateway adapters. -A 'StreamingSourceHandler' will handle the activities sent from the pretend activity feed server and put the data into Diffusion topics. -A 'PollingSourceHandler' will periodically poll and request the activities snapshot from the pretend activity feed server. +## Instructions +Developing the activity feed Gateway adapter requires very little code and just some configuration. Here's what we are going to create: +- A class that implements the `GatewayApplication` interface. +- A class that implements the `PollingSourceHandler` interface. +- A class that implements the `StreamingSourceHandler` interface. +- Simple Gateway adapter runner. +- Create a Gateway adapter configuration file that will be used to configure our streaming and polling handlers. + +### Gateway application +The class is a standard way of writing Gateway adapters. An adapter can then have different types of ServiceHandler for handling streaming, polling or sinking data against various data sources. + + +The ActivityFeedGatewayApplication class, which implements the GatewayApplication interface. Here we'll need to implement a few methods, such as: +- getApplicationDetails +- stop + +Then, based upon the fact we'll be using a streaming and polling handler, these two methods: +- addStreamingSource +- addPollingSource + + +### Gateway application runner +class with a main method, this is a typical idiom used by Gateway adapter Developers for launching the Gateway application. + + +### Polling source handler +We will use this to periodically poll and request the activities snapshot from the pretend activity feed server. + +### Streaming source handler +For our example, this will handle the activities sent from the pretend activity feed server and put the data into Diffusion topics. diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java index 83d9111..73104d6 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java @@ -79,6 +79,7 @@ public StreamingSourceHandler addStreamingSource( StateHandler stateHandler) throws InvalidConfigurationException { +/* final String serviceType = serviceDefinition.getServiceType().getName(); @@ -90,9 +91,11 @@ public StreamingSourceHandler addStreamingSource( stateHandler, objectMapper); } +*/ throw new InvalidConfigurationException( - "Unknown service type: " + serviceType); + "Unknown service type: "); +// + serviceType); } @Override @@ -102,6 +105,7 @@ public PollingSourceHandler addPollingSource( StateHandler stateHandler) throws InvalidConfigurationException { +/* final String serviceType = serviceDefinition.getServiceType().getName(); @@ -113,9 +117,11 @@ public PollingSourceHandler addPollingSource( stateHandler, objectMapper); } +*/ throw new InvalidConfigurationException( - "Unknown service type: " + serviceType); + "Unknown service type: "); +// + serviceType); } @Override From f241ba2eac7a05a17a68508beacf301cfffeec56 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Fri, 22 Nov 2024 11:07:42 +0000 Subject: [PATCH 13/33] DOC-428: activity feed streaming and polling example. --- .../activity/feed/ActivityFeedGatewayApplication.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java index 73104d6..83d9111 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java @@ -79,7 +79,6 @@ public StreamingSourceHandler addStreamingSource( StateHandler stateHandler) throws InvalidConfigurationException { -/* final String serviceType = serviceDefinition.getServiceType().getName(); @@ -91,11 +90,9 @@ public StreamingSourceHandler addStreamingSource( stateHandler, objectMapper); } -*/ throw new InvalidConfigurationException( - "Unknown service type: "); -// + serviceType); + "Unknown service type: " + serviceType); } @Override @@ -105,7 +102,6 @@ public PollingSourceHandler addPollingSource( StateHandler stateHandler) throws InvalidConfigurationException { -/* final String serviceType = serviceDefinition.getServiceType().getName(); @@ -117,11 +113,9 @@ public PollingSourceHandler addPollingSource( stateHandler, objectMapper); } -*/ throw new InvalidConfigurationException( - "Unknown service type: "); -// + serviceType); + "Unknown service type: " + serviceType); } @Override From df74666ea7ac4034397cf7fdca37831796a96a1f Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Fri, 22 Nov 2024 13:15:12 +0000 Subject: [PATCH 14/33] DOC-428: activity feed streaming and polling example. --- activity-feed-adapter/README.md | 192 +++++++++++++++--- .../feed/ActivityFeedGatewayApplication.java | 9 +- 2 files changed, 173 insertions(+), 28 deletions(-) diff --git a/activity-feed-adapter/README.md b/activity-feed-adapter/README.md index 4d3844d..3906a39 100644 --- a/activity-feed-adapter/README.md +++ b/activity-feed-adapter/README.md @@ -15,7 +15,9 @@ of Diffusion. java -Dgateway.config.file=activity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\activity-feed-adapter\target\activity-feed-adapter-1.0.0-jar-with-dependencies.jar -# Activity Feed Gateway Adapter Example +--- +# Activity feed Gateway adapter example + ## Introduction In this tutorial, you will learn how to use the 'Diffusion Gateway Framework' to develop a Gateway adapter for feeding streaming and batch/polled data into your Diffusion server. The Gateway framework makes it easy to integrate with different datasources for getting data in and out of Diffusion. You will see how the Gateway Framework provides a common and consistent application structure and a higher level of abstraction over the Diffusion SDK, as well as handling things like retries and timeouts. @@ -75,10 +77,10 @@ To get started with the sports activity feed example, you will need the followin - Java 11. - Your preferred Java IDE. - A running Diffusion server, this can be running locally or remotely; some of the options are: - - Install Diffusion via the standard Diffusion installer. - - Use the Diffusion Docker image to run a container. - - Use Diffusion Cloud, the DiffusionData SaaS offering. - - Connect to a Diffusion server running remotely. + - Install Diffusion via the standard Diffusion installer. + - Use the Diffusion Docker image to run a container. + - Use Diffusion Cloud, the DiffusionData SaaS offering. + - Connect to a Diffusion server running remotely. The activity feed example code is available on GitHub and is part of the overall Gateway examples project: * [diffusiondata/gateway-examples](https://github.com/diffusiondata/gateway-examples) @@ -93,25 +95,169 @@ Developing the activity feed Gateway adapter requires very little code and just - Simple Gateway adapter runner. - Create a Gateway adapter configuration file that will be used to configure our streaming and polling handlers. -### Gateway application -The class is a standard way of writing Gateway adapters. An adapter can then have different types of ServiceHandler for handling streaming, polling or sinking data against various data sources. - - -The ActivityFeedGatewayApplication class, which implements the GatewayApplication interface. Here we'll need to implement a few methods, such as: -- getApplicationDetails -- stop - -Then, based upon the fact we'll be using a streaming and polling handler, these two methods: -- addStreamingSource -- addPollingSource - - -### Gateway application runner -class with a main method, this is a typical idiom used by Gateway adapter Developers for launching the Gateway application. +Note: the code is available in GitHub, so you may find referring to the completed solution helpful. + +### Gateway application class +Firstly, create a class called `ActivityFeedGatewayApplication` that implements the `GatewayApplication` interface. The class is a standard way of writing Gateway adapters. An adapter can then have different types of `ServiceHandler` for handling streaming, polling or sinking data against your chosen datasources. You will need to implement a few methods, such as: +- `getApplicationDetails` - provides details of the adapter, such as which types of `ServiceHandler` are available and can be configured. +- `stop` - called when the Gateway adapter is shutdown. + +As we go through the tutorial, you will need to override two methods: +- `addPollingSource` - adds a polling source to the adapter. +- `addStreamingSource` - adds a streaming source to the adapter. + +Because our Gateway adapter will integrate with the pretend activity feed server, we'll pass an `ActivityFeedClient` reference in the constructor for later use by the streaming and polling service handlers. The `ObjectMapper` is used to convert our Activity object into JSON. Our code will initially look something like: + +```java +public class ActivityFeedGatewayApplication + implements GatewayApplication { + + private final ObjectMapper objectMapper; + private final ActivityFeedClient activityFeedClient; + + public ActivityFeedGatewayApplication( + ActivityFeedClient activityFeedClient, + ObjectMapper objectMapper) { + + this.activityFeedClient = activityFeedClient; + this.objectMapper = objectMapper; + } + + @Override + public ApplicationDetails getApplicationDetails() + throws ApplicationConfigurationException { + + // You will add service handlers as you progress through the tutorial + return DiffusionGatewayFramework.newApplicationDetailsBuilder() + .build(APPLICATION_TYPE, 1); + } + + @Override + public CompletableFuture stop() { + LOG.info("Application stop"); + + return CompletableFuture.completedFuture(null); + } +``` +### Gateway application runner class +Create a new class called `Runner` - a simple Java class with a main method; this is a typical idiom Gateway adapters use for launching the Gateway application. + +```java +public class Runner { + public static void main(String[] args) { + final ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + DiffusionGatewayFramework.start( + new ActivityFeedGatewayApplication( + ActivityFeedClientImpl.connectToActivityFeedServer(), + objectMapper); + } +} +``` -### Polling source handler -We will use this to periodically poll and request the activities snapshot from the pretend activity feed server. +### Polling source handler class and configuration +Create a class called `ActivityFeedSnapshotPollingSourceHandlerImpl` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend activity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: +- `poll` - this method is periodically called by the Gateway framework based on configuration. +- `pause` - called when the Gateway adapter enters the paused state. +- `resume` - is called when the Gateway adapter can resume. + +In your `poll` method, we will call the pretend activity feed server's `getLatestActivities()` using the `ActivityFeedClient` reference passed into the constructor. Below is the complete code for the polling source handler: + +```java +public class ActivityFeedSnapshotPollingSourceHandlerImpl + implements PollingSourceHandler { + + static final String DEFAULT_POLLING_TOPIC_PATH = + "polling/activity/feed"; + + private static final Logger LOG = + LoggerFactory.getLogger(ActivityFeedSnapshotPollingSourceHandlerImpl.class); + + private final ActivityFeedClient activityFeedClient; + private final Publisher publisher; + private final StateHandler stateHandler; + private final ObjectMapper objectMapper; + private final String topicPath; + + public ActivityFeedSnapshotPollingSourceHandlerImpl( + ActivityFeedClient activityFeedClient, + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler, + ObjectMapper objectMapper) { + + this.activityFeedClient = activityFeedClient; + this.publisher = publisher; + this.stateHandler = stateHandler; + this.objectMapper = objectMapper; + + topicPath = serviceDefinition.getParameters() + .getOrDefault("topicPath", DEFAULT_POLLING_TOPIC_PATH) + .toString(); + } + + @Override + public CompletableFuture poll() { + final CompletableFuture pollCf = new CompletableFuture<>(); + + if (!stateHandler.getState().equals(ServiceState.ACTIVE)) { + pollCf.complete(null); + + return pollCf; + } + + final Collection activities = + activityFeedClient.getLatestActivities(); + + if (activities.isEmpty()) { + pollCf.complete(null); + + return pollCf; + } + + try { + final String value = objectMapper.writeValueAsString(activities); + + publisher.publish(topicPath, value) + .whenComplete((o, throwable) -> { + if (throwable != null) { + pollCf.completeExceptionally(throwable); + } + else { + pollCf.complete(null); + } + }); + } + catch (JsonProcessingException | + PayloadConversionException e) { + + LOG.error("Cannot publish", e); + pollCf.completeExceptionally(e); + + return pollCf; + } + + return pollCf; + } + + @Override + public CompletableFuture pause(PauseReason reason) { + LOG.info("Paused activity feed polling handler"); + + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture resume(ResumeReason reason) { + LOG.info("Resumed activity feed polling handler"); + + return CompletableFuture.completedFuture(null); + } +} +``` -### Streaming source handler +### Streaming source handler class and configuration For our example, this will handle the activities sent from the pretend activity feed server and put the data into Diffusion topics. + diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java index 83d9111..aed7f5f 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java +++ b/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java @@ -38,19 +38,18 @@ public final class ActivityFeedGatewayApplication private static final Logger LOG = LoggerFactory.getLogger(ActivityFeedGatewayApplication.class); - private final ObjectMapper objectMapper; - private final ActivityFeedClient activityFeedClient; + private final ObjectMapper objectMapper; public ActivityFeedGatewayApplication( ActivityFeedClient activityFeedClient, ObjectMapper objectMapper) { - this.objectMapper = - requireNonNull(objectMapper, "objectMapper"); - this.activityFeedClient = requireNonNull(activityFeedClient, "activityFeedClient"); + + this.objectMapper = + requireNonNull(objectMapper, "objectMapper"); } @Override From 7f08fa3945e2b95261f480e5d368daefb4191394 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sat, 23 Nov 2024 12:49:17 +0000 Subject: [PATCH 15/33] DOC-428: sports activity feed streaming and polling example. --- .../feed/client/ActivityFeedClient.java | 13 -- .../feed/client/ActivityFeedListener.java | 7 - .../client/impl/ActivityFeedClientImpl.java | 51 ----- .../feed/service/ActivityFeedServer.java | 14 -- .../impl/ActivityFeedClientImplTest.java | 81 ------- .../feed/model/ActivityTestUtils.java | 17 -- .../impl/ActivityGeneratorSupplierTest.java | 42 ---- .../PretendActivityFeedServerImplTest.java | 192 ----------------- pom.xml | 2 +- .../README.md | 68 +++--- .../pom.xml | 4 +- .../common/jackson/ObjectMapperUtils.java | 0 .../example/sportsactivity}/feed/Runner.java | 8 +- .../SportsActivityFeedGatewayApplication.java | 26 +-- ...eedListenerStreamingSourceHandlerImpl.java | 40 ++-- ...yFeedSnapshotPollingSourceHandlerImpl.java | 26 ++- .../feed/client/SportsActivityFeedClient.java | 14 ++ .../client/SportsActivityFeedListener.java | 7 + .../impl/SportsActivityFeedClientImpl.java | 57 +++++ .../feed/model/SportsActivity.java | 6 +- .../service/SportsActivityFeedServer.java | 14 ++ .../PretendSportsActivityFeedServerImpl.java | 60 +++--- ...RandomSportsActivityGeneratorSupplier.java | 14 +- .../src/main/resources/configuration.json | 19 +- .../src/main/resources/log4j2.xml | 0 .../common/jackson/ObjectMapperUtilsTest.java | 0 .../sportsactivity}/feed/RunnerTest.java | 2 +- .../feed/SportsActivityJsonTest.java | 6 +- ...rtsActivityFeedGatewayApplicationTest.java | 26 +-- ...istenerStreamingSourceHandlerImplTest.java | 60 +++--- ...dSnapshotPollingSourceHandlerImplTest.java | 38 ++-- ...portsSportsActivityFeedClientImplTest.java | 81 +++++++ .../feed/model/ActivityTestUtils.java | 17 ++ ...portsSportsActivityFeedServerImplTest.java | 200 ++++++++++++++++++ ...omSportsActivityGeneratorSupplierTest.java | 42 ++++ 35 files changed, 625 insertions(+), 629 deletions(-) delete mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedClient.java delete mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedListener.java delete mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImpl.java delete mode 100644 activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/ActivityFeedServer.java delete mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImplTest.java delete mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java delete mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplierTest.java delete mode 100644 activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImplTest.java rename {activity-feed-adapter => sports-activity-feed-adapter}/README.md (67%) rename {activity-feed-adapter => sports-activity-feed-adapter}/pom.xml (96%) rename {activity-feed-adapter => sports-activity-feed-adapter}/src/main/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtils.java (100%) rename {activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity => sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity}/feed/Runner.java (65%) rename activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java => sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java (81%) rename activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java => sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java (70%) rename activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java => sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java (79%) create mode 100644 sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedClient.java create mode 100644 sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedListener.java create mode 100644 sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImpl.java rename activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/model/Activity.java => sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/SportsActivity.java (87%) create mode 100644 sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/SportsActivityFeedServer.java rename activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImpl.java => sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImpl.java (65%) rename activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplier.java => sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivityGeneratorSupplier.java (78%) rename {activity-feed-adapter => sports-activity-feed-adapter}/src/main/resources/configuration.json (60%) rename {activity-feed-adapter => sports-activity-feed-adapter}/src/main/resources/log4j2.xml (100%) rename {activity-feed-adapter => sports-activity-feed-adapter}/src/test/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtilsTest.java (100%) rename {activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity => sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity}/feed/RunnerTest.java (87%) rename activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityJsonTest.java => sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java (75%) rename activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplicationTest.java => sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java (81%) rename activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java => sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java (69%) rename activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImplTest.java => sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java (81%) create mode 100644 sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java create mode 100644 sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/ActivityTestUtils.java create mode 100644 sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java create mode 100644 sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivityGeneratorSupplierTest.java diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedClient.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedClient.java deleted file mode 100644 index ef780b8..0000000 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedClient.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.diffusiondata.pretend.example.activity.feed.client; - -import java.util.Collection; - -import com.diffusiondata.pretend.example.activity.feed.model.Activity; - -public interface ActivityFeedClient { - String registerListener(ActivityFeedListener activityFeedListener); - - boolean unregisterListener(String listenerIdentifier); - - Collection getLatestActivities(); -} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedListener.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedListener.java deleted file mode 100644 index 42a682c..0000000 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/ActivityFeedListener.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.diffusiondata.pretend.example.activity.feed.client; - -import com.diffusiondata.pretend.example.activity.feed.model.Activity; - -public interface ActivityFeedListener { - void onMessage(Activity activity); -} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImpl.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImpl.java deleted file mode 100644 index e89d011..0000000 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImpl.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.diffusiondata.pretend.example.activity.feed.client.impl; - -import java.util.Collection; - -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; -import com.diffusiondata.pretend.example.activity.feed.service.ActivityFeedServer; -import com.diffusiondata.pretend.example.activity.feed.service.impl.PretendActivityFeedServerImpl; - -import net.jcip.annotations.Immutable; - -@Immutable -public final class ActivityFeedClientImpl - implements ActivityFeedClient { - - private final ActivityFeedServer activityFeedServer; - - private ActivityFeedClientImpl(ActivityFeedServer activityFeedServer) { - this.activityFeedServer = activityFeedServer; - } - - @Override - public String registerListener(ActivityFeedListener activityFeedListener) { - return activityFeedServer.registerClientListener(activityFeedListener); - } - - @Override - public boolean unregisterListener(String listenerIdentifier) { - return activityFeedServer.unregisterClientListener(listenerIdentifier); - } - - @Override - public Collection getLatestActivities() { - return activityFeedServer.getLatestActivities(); - } - - public static ActivityFeedClient connectToActivityFeedServer() { - return connectToActivityFeedServer(PretendActivityFeedServerImpl - .createAndStartActivityFeedServer()); - } - - /** - * package for tests. - */ - static ActivityFeedClient connectToActivityFeedServer( - ActivityFeedServer activityFeedServer) { - - return new ActivityFeedClientImpl(activityFeedServer); - } -} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/ActivityFeedServer.java b/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/ActivityFeedServer.java deleted file mode 100644 index 932e24e..0000000 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/ActivityFeedServer.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.diffusiondata.pretend.example.activity.feed.service; - -import java.util.Collection; - -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; - -public interface ActivityFeedServer { - String registerClientListener(ActivityFeedListener activityFeedListener); - - boolean unregisterClientListener(String listenerIdentifier); - - Collection getLatestActivities(); -} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImplTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImplTest.java deleted file mode 100644 index 81ea3b3..0000000 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/client/impl/ActivityFeedClientImplTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.diffusiondata.pretend.example.activity.feed.client.impl; - -import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.core.IsEqual.equalTo; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import java.util.Collection; -import java.util.List; - -import org.junit.jupiter.api.AfterEach; -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 com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; -import com.diffusiondata.pretend.example.activity.feed.service.ActivityFeedServer; - -@ExtendWith(MockitoExtension.class) -class ActivityFeedClientImplTest { - @Mock - private ActivityFeedServer activityFeedServerMock; - - private ActivityFeedClient activityFeedClient; - - @BeforeEach - void beforeEachTest() { - activityFeedClient = - ActivityFeedClientImpl.connectToActivityFeedServer(activityFeedServerMock); - } - - @AfterEach - void afterEachTest() { - verifyNoMoreInteractions(activityFeedServerMock); - } - - @Test - void testRegisterListener() { - final String expectedIdentifier = "listener-id-1"; - final ActivityFeedListener listener = mock(ActivityFeedListener.class); - - when(activityFeedServerMock.registerClientListener(listener)) - .thenReturn(expectedIdentifier); - - final String listenerIdentifier = - activityFeedClient.registerListener(listener); - - assertThat(listenerIdentifier, equalTo(expectedIdentifier)); - } - - @Test - void testUnregisterListener() { - when(activityFeedServerMock.unregisterClientListener("abc")) - .thenReturn(true); - - final boolean result = - activityFeedClient.unregisterListener("abc"); - - assertThat(result, equalTo(true)); - } - - @Test - void testGetActivityFeed() { - final Collection expectedActivities = - List.of(createPopulatedActivity()); - - when(activityFeedServerMock.getLatestActivities()) - .thenReturn(expectedActivities); - - final Collection latestActivities = - activityFeedClient.getLatestActivities(); - - assertThat(latestActivities, equalTo(expectedActivities)); - } -} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java deleted file mode 100644 index 838047c..0000000 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/model/ActivityTestUtils.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.diffusiondata.pretend.example.activity.feed.model; - -import java.time.Instant; - -public final class ActivityTestUtils { - private ActivityTestUtils() { - // Private constructor to prevent creation - } - - public static Activity createPopulatedActivity() { - return createPopulatedActivity("some-sport"); - } - - public static Activity createPopulatedActivity(String sport) { - return new Activity(sport, "c", "w", Instant.now()); - } -} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplierTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplierTest.java deleted file mode 100644 index d69338c..0000000 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplierTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.diffusiondata.pretend.example.activity.feed.service.impl; - -import static java.util.concurrent.TimeUnit.MINUTES; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.lessThan; -import static org.hamcrest.core.IsNull.notNullValue; - -import java.time.Instant; -import java.util.function.Supplier; - -import org.junit.jupiter.api.Test; - -import com.diffusiondata.pretend.example.activity.feed.model.Activity; - -import net.datafaker.Faker; - -class ActivityGeneratorSupplierTest { - private final Supplier supplier = - new ActivityGeneratorSupplier(new Faker()); - - @Test - void testGet() { - final Activity activity = supplier.get(); - - assertThat(activity, notNullValue()); - assertThat(activity.getSport(), notNullValue()); - assertThat(activity.getCountry(), notNullValue()); - assertThat(activity.getWinner(), notNullValue()); - assertThat(activity.getDateOfActivity(), notNullValue()); - } - - @Test - void testTimeAndDatePast() { - final ActivityGeneratorSupplier impl = - (ActivityGeneratorSupplier) supplier; - - final Instant pastDate = impl.timeAndDatePast(2, MINUTES); - - assertThat(pastDate.toEpochMilli(), - lessThan(System.currentTimeMillis())); - } -} diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImplTest.java b/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImplTest.java deleted file mode 100644 index 6e2a46e..0000000 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImplTest.java +++ /dev/null @@ -1,192 +0,0 @@ -package com.diffusiondata.pretend.example.activity.feed.service.impl; - -import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.collection.IsEmptyIterable.emptyIterable; -import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize; -import static org.hamcrest.collection.IsMapContaining.hasKey; -import static org.hamcrest.collection.IsMapWithSize.aMapWithSize; -import static org.hamcrest.collection.IsMapWithSize.anEmptyMap; -import static org.hamcrest.core.IsEqual.equalTo; -import static org.hamcrest.core.IsNull.notNullValue; -import static org.hamcrest.core.IsSame.sameInstance; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import java.util.Collection; -import java.util.Map; -import java.util.concurrent.ExecutorService; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; -import com.diffusiondata.pretend.example.activity.feed.service.ActivityFeedServer; - -@ExtendWith(MockitoExtension.class) -class PretendActivityFeedServerImplTest { - private static final String SPORT = "tennis"; - - @Mock - private ExecutorService executorServiceMock; - - @Mock - private ActivityGeneratorSupplier activityGeneratorSupplierMock; - - @Mock - private ActivityFeedListener activityFeedListenerMock; - - private ActivityFeedServer activityFeedServer; - - @BeforeEach - void beforeEachTest() { - when(executorServiceMock.submit(any(Runnable.class))) - .thenReturn(null); - - activityFeedServer = - PretendActivityFeedServerImpl.createAndStartActivityFeedServer( - executorServiceMock, - activityGeneratorSupplierMock, - 0); - } - - @AfterEach - void afterEachTest() { - verifyNoMoreInteractions( - executorServiceMock, - activityGeneratorSupplierMock, - activityFeedListenerMock - ); - } - - @Test - @Order(10) - void testRegisterClientListener() { - final String listenerIdentifier = - activityFeedServer.registerClientListener(activityFeedListenerMock); - - assertThat(listenerIdentifier, notNullValue()); - - final Map listeners = - getImpl().getListeners(); - - assertThat(listeners, aMapWithSize(1)); - assertThat(listeners, hasKey(listenerIdentifier)); - assertThat(listeners.get(listenerIdentifier), - sameInstance(activityFeedListenerMock)); - } - - @Test - @Order(20) - void testUnregisterClientListenerWhenExists() { - final String listenerIdentifier = - activityFeedServer.registerClientListener(activityFeedListenerMock); - - final boolean result = - activityFeedServer.unregisterClientListener(listenerIdentifier); - - assertThat(result, equalTo(true)); - - final Map listeners = - getImpl().getListeners(); - - assertThat(listeners, anEmptyMap()); - } - - @Test - void testUnregisterClientListenerWhenDoesNotExists() { - final boolean result = - activityFeedServer.unregisterClientListener( - "unknown-^^-listener-**-identifier"); - - assertThat(result, equalTo(false)); - } - - @Test - void testGetLatestActivitiesWhenNoneGenerated() { - final Collection latestActivities = - activityFeedServer.getLatestActivities(); - - assertThat(latestActivities, emptyIterable()); - } - - @Test - @Order(30) - void testInternalUpdateStateAndListenersWhenListenerRegistered() { - final Activity activity = createPopulatedActivity(SPORT); - - doNothing().when(activityFeedListenerMock) - .onMessage(activity); - - activityFeedServer.registerClientListener(activityFeedListenerMock); - - getImpl().internalUpdateStateAndListeners(activity); - - checkCachedLatestActivitiesAsExpected(); - } - - @Test - @Order(33) - void testInternalUpdateStateAndListenersWhenNoListenersRegistered() { - final Activity activity = createPopulatedActivity(SPORT); - - getImpl().internalUpdateStateAndListeners(activity); - - checkCachedLatestActivitiesAsExpected(); - } - - @Test - @Order(36) - void testInternalUpdateStateAndListenersWhenListenerRegisterAndExceptions() { - final Activity activity = createPopulatedActivity(SPORT); - - doThrow(IllegalStateException.class).when(activityFeedListenerMock) - .onMessage(activity); - - activityFeedServer.registerClientListener(activityFeedListenerMock); - - getImpl().internalUpdateStateAndListeners(activity); - - checkCachedLatestActivitiesAsExpected(); - } - - @Test - @Order(40) - void testGetLatestActivitiesWhenSomeGenerated() - throws Exception { - - final Activity activity = createPopulatedActivity(SPORT); - - when(activityGeneratorSupplierMock.get()) - .thenReturn(activity); - - getImpl().runOnce(); - - final Collection latestActivities = - activityFeedServer.getLatestActivities(); - - assertThat(latestActivities, iterableWithSize(1)); - assertThat(latestActivities.iterator().next(), equalTo(activity)); - } - - private PretendActivityFeedServerImpl getImpl() { - return (PretendActivityFeedServerImpl) activityFeedServer; - } - - private void checkCachedLatestActivitiesAsExpected() { - final Map cachedLatestActivities = - getImpl().getCachedLatestActivities(); - - assertThat(cachedLatestActivities, aMapWithSize(1)); - assertThat(cachedLatestActivities, hasKey(SPORT)); - } -} diff --git a/pom.xml b/pom.xml index e1377b0..afebe2f 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ sample-diffusion-adapter human-diffusion-adapter languageConverter - activity-feed-adapter + sports-activity-feed-adapter diff --git a/activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md similarity index 67% rename from activity-feed-adapter/README.md rename to sports-activity-feed-adapter/README.md index 3906a39..71b7d0f 100644 --- a/activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -12,7 +12,7 @@ of Diffusion. ## How to run the Activity feed Gateway adapter - java -Dgateway.config.file=activity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\activity-feed-adapter\target\activity-feed-adapter-1.0.0-jar-with-dependencies.jar + java -Dgateway.config.file=sportsActivity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\sportsActivity-feed-adapter\target\sportsActivity-feed-adapter-1.0.0-jar-with-dependencies.jar --- @@ -24,26 +24,26 @@ In this tutorial, you will learn how to use the 'Diffusion Gateway Framework' to After completing the tutorial, you can expect to understand how the Gateway framework helps to quickly develop adapters for getting data in and out of Diffusion. You can review and understand the solution code, learn how to run the example, and see the data updated in the Diffusion Console. ## Overview -This example uses the concept of a sporting activity feed (think along the lines of popular exercise/social networks). Naturally, we don't build the platform for this tutorial; instead, we use a pretend activity feed server that generates realistic random sports activity data. +This example uses the concept of a sporting sportsActivity feed (think along the lines of popular exercise/social networks). Naturally, we don't build the platform for this tutorial; instead, we use a pretend sportsActivity feed server that generates realistic random sports sportsActivity data. -The pretend activity feed server provides a client API that allows an application to subscribe to a feed of activities, with the changes pushed to the subscribed clients as they happen - so, this would be like someone completing an activity, uploading it and then the activity is sent as an event to subscribers. Additionally, the pretend activity feed client API has a mechanism for requesting a snapshot of the latest activities at a point in time. +The pretend sportsActivity feed server provides a client API that allows an application to subscribe to a feed of activities, with the changes pushed to the subscribed clients as they happen - so, this would be like someone completing an sportsActivity, uploading it and then the sportsActivity is sent as an event to subscribers. Additionally, the pretend sportsActivity feed client API has a mechanism for requesting a snapshot of the latest activities at a point in time. -In this tutorial, we'll integrate the Gateway Framework with the pretend activity feed server and demonstrate data streaming into the Gateway adapter and polling to receive the activity snapshot. +In this tutorial, we'll integrate the Gateway Framework with the pretend sportsActivity feed server and demonstrate data streaming into the Gateway adapter and polling to receive the sportsActivity snapshot. The final solution comprises the following: -- Pretend sports activity feed server - this provides a client API for receiving streaming events and the ability to request a snapshot of the latest activities. -- Gateway adapter - this is the application you will learn to build; it will integrate with the pretend sports activity feed server and put the data into your Diffusion server. +- Pretend sports sportsActivity feed server - this provides a client API for receiving streaming events and the ability to request a snapshot of the latest activities. +- Gateway adapter - this is the application you will learn to build; it will integrate with the pretend sports sportsActivity feed server and put the data into your Diffusion server. - Diffusion server - this is a running instance of Diffusion; you can run Diffusion in several different ways, such as running locally on your machine or connecting to a remote Diffusion server. -The activity domain object has the following attributes: -- **Sport:** the sporting activity, such as swimming, sailing, tennis and other sports. -- **Country:** the country where the sports activity took place. -- **Winner:** the name of the person who won the sporting activity. -- **Date of activity:** when the sporting activity took place. +The sportsActivity domain object has the following attributes: +- **Sport:** the sporting sportsActivity, such as swimming, sailing, tennis and other sports. +- **Country:** the country where the sports sportsActivity took place. +- **Winner:** the name of the person who won the sporting sportsActivity. +- **Date of sportsActivity:** when the sporting sportsActivity took place. -The pretend activity feed client has the following features: -- **Register a listener:** an activity feed listener instance is required, with a callback method of 'onMessage' called when a new activity is sent to the subscriber (in our case, the Gateway adapter). -- **Unregister a listener:** a way of unregistering from the activity feed to stop receiving updates. +The pretend sportsActivity feed client has the following features: +- **Register a listener:** an sportsActivity feed listener instance is required, with a callback method of 'onMessage' called when a new sportsActivity is sent to the subscriber (in our case, the Gateway adapter). +- **Unregister a listener:** a way of unregistering from the sportsActivity feed to stop receiving updates. - **Get latest activities:** returns a snapshot list of the latest sporting activities when called. Diagram of final solution: @@ -59,11 +59,11 @@ DIF("Diffusion
server"):::blue %% Edges -AFS -. 1a) Send activity event .-> AFG +AFS -. 1a) Send sportsActivity event .-> AFG AFG -- 2a) Invoke get latest activities ---> AFS AFS -- 2b) Return latest activities --> AFG -AFG -- 1b) Update specific \n sport activity --> DIF +AFG -- 1b) Update specific \n sport sportsActivity --> DIF AFG -- 2c) Update the \n activities snapshot ---> DIF %% Styling @@ -73,7 +73,7 @@ classDef blue fill:#BBDEFB,stroke:#1976D2,stroke-width:2px; ``` ## Prerequisites -To get started with the sports activity feed example, you will need the following: +To get started with the sports sportsActivity feed example, you will need the following: - Java 11. - Your preferred Java IDE. - A running Diffusion server, this can be running locally or remotely; some of the options are: @@ -82,13 +82,13 @@ To get started with the sports activity feed example, you will need the followin - Use Diffusion Cloud, the DiffusionData SaaS offering. - Connect to a Diffusion server running remotely. -The activity feed example code is available on GitHub and is part of the overall Gateway examples project: +The sportsActivity feed example code is available on GitHub and is part of the overall Gateway examples project: * [diffusiondata/gateway-examples](https://github.com/diffusiondata/gateway-examples) -Follow the README file within the activity-feed-adapter module to start building the project and running the example. +Follow the README file within the sportsActivity-feed-adapter module to start building the project and running the example. ## Instructions -Developing the activity feed Gateway adapter requires very little code and just some configuration. Here's what we are going to create: +Developing the sportsActivity feed Gateway adapter requires very little code and just some configuration. Here's what we are going to create: - A class that implements the `GatewayApplication` interface. - A class that implements the `PollingSourceHandler` interface. - A class that implements the `StreamingSourceHandler` interface. @@ -106,20 +106,20 @@ As we go through the tutorial, you will need to override two methods: - `addPollingSource` - adds a polling source to the adapter. - `addStreamingSource` - adds a streaming source to the adapter. -Because our Gateway adapter will integrate with the pretend activity feed server, we'll pass an `ActivityFeedClient` reference in the constructor for later use by the streaming and polling service handlers. The `ObjectMapper` is used to convert our Activity object into JSON. Our code will initially look something like: +Because our Gateway adapter will integrate with the pretend sportsActivity feed server, we'll pass an `ActivityFeedClient` reference in the constructor for later use by the streaming and polling service handlers. The `ObjectMapper` is used to convert our Activity object into JSON. Our code will initially look something like: ```java public class ActivityFeedGatewayApplication implements GatewayApplication { private final ObjectMapper objectMapper; - private final ActivityFeedClient activityFeedClient; + private final ActivityFeedClient sportsActivityFeedClient; public ActivityFeedGatewayApplication( - ActivityFeedClient activityFeedClient, + ActivityFeedClient sportsActivityFeedClient, ObjectMapper objectMapper) { - this.activityFeedClient = activityFeedClient; + this.sportsActivityFeedClient = sportsActivityFeedClient; this.objectMapper = objectMapper; } @@ -158,37 +158,37 @@ public class Runner { ``` ### Polling source handler class and configuration -Create a class called `ActivityFeedSnapshotPollingSourceHandlerImpl` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend activity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: +Create a class called `ActivityFeedSnapshotPollingSourceHandlerImpl` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend sportsActivity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: - `poll` - this method is periodically called by the Gateway framework based on configuration. - `pause` - called when the Gateway adapter enters the paused state. - `resume` - is called when the Gateway adapter can resume. -In your `poll` method, we will call the pretend activity feed server's `getLatestActivities()` using the `ActivityFeedClient` reference passed into the constructor. Below is the complete code for the polling source handler: +In your `poll` method, we will call the pretend sportsActivity feed server's `getLatestActivities()` using the `ActivityFeedClient` reference passed into the constructor. Below is the complete code for the polling source handler: ```java public class ActivityFeedSnapshotPollingSourceHandlerImpl implements PollingSourceHandler { static final String DEFAULT_POLLING_TOPIC_PATH = - "polling/activity/feed"; + "polling/sportsActivity/feed"; private static final Logger LOG = LoggerFactory.getLogger(ActivityFeedSnapshotPollingSourceHandlerImpl.class); - private final ActivityFeedClient activityFeedClient; + private final ActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; private final StateHandler stateHandler; private final ObjectMapper objectMapper; private final String topicPath; public ActivityFeedSnapshotPollingSourceHandlerImpl( - ActivityFeedClient activityFeedClient, + ActivityFeedClient sportsActivityFeedClient, ServiceDefinition serviceDefinition, Publisher publisher, StateHandler stateHandler, ObjectMapper objectMapper) { - this.activityFeedClient = activityFeedClient; + this.sportsActivityFeedClient = sportsActivityFeedClient; this.publisher = publisher; this.stateHandler = stateHandler; this.objectMapper = objectMapper; @@ -209,7 +209,7 @@ public class ActivityFeedSnapshotPollingSourceHandlerImpl } final Collection activities = - activityFeedClient.getLatestActivities(); + sportsActivityFeedClient.getLatestActivities(); if (activities.isEmpty()) { pollCf.complete(null); @@ -244,14 +244,14 @@ public class ActivityFeedSnapshotPollingSourceHandlerImpl @Override public CompletableFuture pause(PauseReason reason) { - LOG.info("Paused activity feed polling handler"); + LOG.info("Paused sportsActivity feed polling handler"); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture resume(ResumeReason reason) { - LOG.info("Resumed activity feed polling handler"); + LOG.info("Resumed sportsActivity feed polling handler"); return CompletableFuture.completedFuture(null); } @@ -259,5 +259,5 @@ public class ActivityFeedSnapshotPollingSourceHandlerImpl ``` ### Streaming source handler class and configuration -For our example, this will handle the activities sent from the pretend activity feed server and put the data into Diffusion topics. +For our example, this will handle the activities sent from the pretend sportsActivity feed server and put the data into Diffusion topics. diff --git a/activity-feed-adapter/pom.xml b/sports-activity-feed-adapter/pom.xml similarity index 96% rename from activity-feed-adapter/pom.xml rename to sports-activity-feed-adapter/pom.xml index 6305a3a..a07f3b8 100644 --- a/activity-feed-adapter/pom.xml +++ b/sports-activity-feed-adapter/pom.xml @@ -12,7 +12,7 @@ com.diffusiondata.gateway.examples - activity-feed-adapter + sports-activity-feed-adapter 2.16.1 @@ -79,7 +79,7 @@ - com.diffusiondata.gateway.example.activity.feed.Runner + com.diffusiondata.gateway.example.sportsActivity.feed.Runner diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtils.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtils.java similarity index 100% rename from activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtils.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtils.java diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/Runner.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/Runner.java similarity index 65% rename from activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/Runner.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/Runner.java index c368bde..e0ba8b1 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/Runner.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/Runner.java @@ -1,10 +1,10 @@ -package com.diffusiondata.gateway.example.activity.feed; +package com.diffusiondata.gateway.example.sportsactivity.feed; import static com.diffusiondata.gateway.example.common.jackson.ObjectMapperUtils.createAndConfigureObjectMapper; import com.diffusiondata.gateway.framework.DiffusionGatewayFramework; import com.diffusiondata.gateway.framework.GatewayApplication; -import com.diffusiondata.pretend.example.activity.feed.client.impl.ActivityFeedClientImpl; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.impl.SportsActivityFeedClientImpl; public final class Runner { public static void main(String[] args) { @@ -15,8 +15,8 @@ public static void main(String[] args) { * package for tests. */ static GatewayApplication createGatewayApplication() { - return new ActivityFeedGatewayApplication( - ActivityFeedClientImpl.connectToActivityFeedServer(), + return new SportsActivityFeedGatewayApplication( + SportsActivityFeedClientImpl.connectToActivityFeedServer(), createAndConfigureObjectMapper()); } } diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java similarity index 81% rename from activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java index aed7f5f..cbbb856 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplication.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java @@ -1,4 +1,4 @@ -package com.diffusiondata.gateway.example.activity.feed; +package com.diffusiondata.gateway.example.sportsactivity.feed; import static java.util.Objects.requireNonNull; @@ -17,13 +17,13 @@ import com.diffusiondata.gateway.framework.StreamingSourceHandler; import com.diffusiondata.gateway.framework.exceptions.ApplicationConfigurationException; import com.diffusiondata.gateway.framework.exceptions.InvalidConfigurationException; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; import com.fasterxml.jackson.databind.ObjectMapper; import net.jcip.annotations.Immutable; @Immutable -public final class ActivityFeedGatewayApplication +public final class SportsActivityFeedGatewayApplication implements GatewayApplication { static final String APPLICATION_TYPE = @@ -36,17 +36,17 @@ public final class ActivityFeedGatewayApplication "polling-activity-feed-service"; private static final Logger LOG = - LoggerFactory.getLogger(ActivityFeedGatewayApplication.class); + LoggerFactory.getLogger(SportsActivityFeedGatewayApplication.class); - private final ActivityFeedClient activityFeedClient; + private final SportsActivityFeedClient sportsActivityFeedClient; private final ObjectMapper objectMapper; - public ActivityFeedGatewayApplication( - ActivityFeedClient activityFeedClient, + public SportsActivityFeedGatewayApplication( + SportsActivityFeedClient sportsActivityFeedClient, ObjectMapper objectMapper) { - this.activityFeedClient = - requireNonNull(activityFeedClient, "activityFeedClient"); + this.sportsActivityFeedClient = + requireNonNull(sportsActivityFeedClient, "activityFeedClient"); this.objectMapper = requireNonNull(objectMapper, "objectMapper"); @@ -82,8 +82,8 @@ public StreamingSourceHandler addStreamingSource( serviceDefinition.getServiceType().getName(); if (STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { - return new ActivityFeedListenerStreamingSourceHandlerImpl( - activityFeedClient, + return new SportsActivityFeedListenerStreamingSourceHandlerImpl( + sportsActivityFeedClient, serviceDefinition, publisher, stateHandler, @@ -105,8 +105,8 @@ public PollingSourceHandler addPollingSource( serviceDefinition.getServiceType().getName(); if (POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { - return new ActivityFeedSnapshotPollingSourceHandlerImpl( - activityFeedClient, + return new SportsActivityFeedSnapshotPollingSourceHandlerImpl( + sportsActivityFeedClient, serviceDefinition, publisher, stateHandler, diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java similarity index 70% rename from activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java index e6014d7..b9c6d23 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java @@ -1,4 +1,4 @@ -package com.diffusiondata.gateway.example.activity.feed; +package com.diffusiondata.gateway.example.sportsactivity.feed; import static java.util.Objects.requireNonNull; @@ -13,26 +13,26 @@ import com.diffusiondata.gateway.framework.StateHandler; import com.diffusiondata.gateway.framework.StreamingSourceHandler; import com.diffusiondata.gateway.framework.exceptions.PayloadConversionException; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import net.jcip.annotations.ThreadSafe; @ThreadSafe -public final class ActivityFeedListenerStreamingSourceHandlerImpl - implements ActivityFeedListener, +public final class SportsActivityFeedListenerStreamingSourceHandlerImpl + implements SportsActivityFeedListener, StreamingSourceHandler { static final String DEFAULT_STREAMING_TOPIC_PREFIX = "streaming/activity/feed"; private static final Logger LOG = - LoggerFactory.getLogger(ActivityFeedListenerStreamingSourceHandlerImpl.class); + LoggerFactory.getLogger(SportsActivityFeedListenerStreamingSourceHandlerImpl.class); - private final ActivityFeedClient activityFeedClient; + private final SportsActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; private final StateHandler stateHandler; private final ObjectMapper objectMapper; @@ -40,15 +40,15 @@ public final class ActivityFeedListenerStreamingSourceHandlerImpl private String listenerIdentifier; - public ActivityFeedListenerStreamingSourceHandlerImpl( - ActivityFeedClient activityFeedClient, + public SportsActivityFeedListenerStreamingSourceHandlerImpl( + SportsActivityFeedClient sportsActivityFeedClient, ServiceDefinition serviceDefinition, Publisher publisher, StateHandler stateHandler, ObjectMapper objectMapper) { - this.activityFeedClient = - requireNonNull(activityFeedClient, "activityFeedClient"); + this.sportsActivityFeedClient = + requireNonNull(sportsActivityFeedClient, "activityFeedClient"); this.publisher = requireNonNull(publisher, "publisher"); this.stateHandler = requireNonNull(stateHandler, "stateHandler"); @@ -61,13 +61,13 @@ public ActivityFeedListenerStreamingSourceHandlerImpl( } @Override - public void onMessage(Activity activity) { - requireNonNull(activity, "activity"); + public void onMessage(SportsActivity sportsActivity) { + requireNonNull(sportsActivity, "activity"); if (stateHandler.getState().equals(ServiceState.ACTIVE)) { try { - final String topicPath = topicPrefix + "/" + activity.getSport(); - final String value = objectMapper.writeValueAsString(activity); + final String topicPath = topicPrefix + "/" + sportsActivity.getSport(); + final String value = objectMapper.writeValueAsString(sportsActivity); publisher.publish(topicPath, value) .exceptionally(throwable -> { @@ -88,7 +88,7 @@ public void onMessage(Activity activity) { @Override public CompletableFuture start() { listenerIdentifier = - activityFeedClient.registerListener(this); + sportsActivityFeedClient.registerListener(this); LOG.info("Started activity feed streaming handler"); @@ -97,7 +97,7 @@ public CompletableFuture start() { @Override public CompletableFuture stop() { - activityFeedClient.unregisterListener(listenerIdentifier); + sportsActivityFeedClient.unregisterListener(listenerIdentifier); LOG.info("Stopped activity feed streaming handler"); @@ -106,7 +106,7 @@ public CompletableFuture stop() { @Override public CompletableFuture pause(PauseReason reason) { - activityFeedClient.unregisterListener(listenerIdentifier); + sportsActivityFeedClient.unregisterListener(listenerIdentifier); LOG.info("Paused activity feed streaming handler"); @@ -116,7 +116,7 @@ public CompletableFuture pause(PauseReason reason) { @Override public CompletableFuture resume(ResumeReason reason) { listenerIdentifier = - activityFeedClient.registerListener(this); + sportsActivityFeedClient.registerListener(this); LOG.info("Resumed activity feed streaming handler"); diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java similarity index 79% rename from activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java index b2b07ca..3d6539a 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java @@ -1,4 +1,4 @@ -package com.diffusiondata.gateway.example.activity.feed; +package com.diffusiondata.gateway.example.sportsactivity.feed; import static java.util.Objects.requireNonNull; @@ -14,38 +14,38 @@ import com.diffusiondata.gateway.framework.ServiceState; import com.diffusiondata.gateway.framework.StateHandler; import com.diffusiondata.gateway.framework.exceptions.PayloadConversionException; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import net.jcip.annotations.ThreadSafe; @ThreadSafe -public final class ActivityFeedSnapshotPollingSourceHandlerImpl +public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl implements PollingSourceHandler { static final String DEFAULT_POLLING_TOPIC_PATH = "polling/activity/feed"; private static final Logger LOG = - LoggerFactory.getLogger(ActivityFeedSnapshotPollingSourceHandlerImpl.class); + LoggerFactory.getLogger(SportsActivityFeedSnapshotPollingSourceHandlerImpl.class); - private final ActivityFeedClient activityFeedClient; + private final SportsActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; private final StateHandler stateHandler; private final ObjectMapper objectMapper; private final String topicPath; - public ActivityFeedSnapshotPollingSourceHandlerImpl( - ActivityFeedClient activityFeedClient, + public SportsActivityFeedSnapshotPollingSourceHandlerImpl( + SportsActivityFeedClient sportsActivityFeedClient, ServiceDefinition serviceDefinition, Publisher publisher, StateHandler stateHandler, ObjectMapper objectMapper) { - this.activityFeedClient = - requireNonNull(activityFeedClient, "activityFeedClient"); + this.sportsActivityFeedClient = + requireNonNull(sportsActivityFeedClient, "activityFeedClient"); this.publisher = requireNonNull(publisher, "publisher"); this.stateHandler = requireNonNull(stateHandler, "stateHandler"); @@ -67,8 +67,8 @@ public CompletableFuture poll() { return pollCf; } - final Collection activities = - activityFeedClient.getLatestActivities(); + final Collection activities = + sportsActivityFeedClient.getLatestActivities(); if (activities.isEmpty()) { pollCf.complete(null); @@ -94,8 +94,6 @@ public CompletableFuture poll() { LOG.error("Cannot publish", e); pollCf.completeExceptionally(e); - - return pollCf; } return pollCf; diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedClient.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedClient.java new file mode 100644 index 0000000..7b69acd --- /dev/null +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedClient.java @@ -0,0 +1,14 @@ +package com.diffusiondata.pretend.example.sportsactivity.feed.client; + +import java.util.Collection; + +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; + +public interface SportsActivityFeedClient { + String registerListener( + SportsActivityFeedListener sportsActivityFeedListener); + + boolean unregisterListener(String listenerIdentifier); + + Collection getLatestActivities(); +} diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedListener.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedListener.java new file mode 100644 index 0000000..54fcf84 --- /dev/null +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedListener.java @@ -0,0 +1,7 @@ +package com.diffusiondata.pretend.example.sportsactivity.feed.client; + +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; + +public interface SportsActivityFeedListener { + void onMessage(SportsActivity sportsActivity); +} diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImpl.java new file mode 100644 index 0000000..d64dcd6 --- /dev/null +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImpl.java @@ -0,0 +1,57 @@ +package com.diffusiondata.pretend.example.sportsactivity.feed.client.impl; + +import java.util.Collection; + +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; +import com.diffusiondata.pretend.example.sportsactivity.feed.service.SportsActivityFeedServer; +import com.diffusiondata.pretend.example.sportsactivity.feed.service.impl.PretendSportsActivityFeedServerImpl; + +import net.jcip.annotations.Immutable; + +@Immutable +public final class SportsActivityFeedClientImpl + implements SportsActivityFeedClient { + + private final SportsActivityFeedServer sportsActivityFeedServer; + + private SportsActivityFeedClientImpl( + SportsActivityFeedServer sportsActivityFeedServer) { + + this.sportsActivityFeedServer = sportsActivityFeedServer; + } + + @Override + public String registerListener( + SportsActivityFeedListener sportsActivityFeedListener) { + + return sportsActivityFeedServer.registerClientListener( + sportsActivityFeedListener); + } + + @Override + public boolean unregisterListener(String listenerIdentifier) { + return sportsActivityFeedServer.unregisterClientListener( + listenerIdentifier); + } + + @Override + public Collection getLatestActivities() { + return sportsActivityFeedServer.getLatestSportsActivities(); + } + + public static SportsActivityFeedClient connectToActivityFeedServer() { + return connectToActivityFeedServer(PretendSportsActivityFeedServerImpl + .createAndStartActivityFeedServer()); + } + + /** + * package for tests. + */ + static SportsActivityFeedClient connectToActivityFeedServer( + SportsActivityFeedServer sportsActivityFeedServer) { + + return new SportsActivityFeedClientImpl(sportsActivityFeedServer); + } +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/model/Activity.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/SportsActivity.java similarity index 87% rename from activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/model/Activity.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/SportsActivity.java index 1eed683..372f0b3 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/model/Activity.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/SportsActivity.java @@ -1,4 +1,4 @@ -package com.diffusiondata.pretend.example.activity.feed.model; +package com.diffusiondata.pretend.example.sportsactivity.feed.model; import java.time.Instant; @@ -7,13 +7,13 @@ import net.jcip.annotations.Immutable; @Immutable -public class Activity { +public class SportsActivity { private final String sport; private final String country; private final String winner; private final Instant dateOfActivity; - public Activity( + public SportsActivity( String sport, String country, String winner, diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/SportsActivityFeedServer.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/SportsActivityFeedServer.java new file mode 100644 index 0000000..b19c31f --- /dev/null +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/SportsActivityFeedServer.java @@ -0,0 +1,14 @@ +package com.diffusiondata.pretend.example.sportsactivity.feed.service; + +import java.util.Collection; + +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; + +public interface SportsActivityFeedServer { + String registerClientListener(SportsActivityFeedListener sportsActivityFeedListener); + + boolean unregisterClientListener(String listenerIdentifier); + + Collection getLatestSportsActivities(); +} diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImpl.java similarity index 65% rename from activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImpl.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImpl.java index 3bc30f5..a2519d6 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/PretendActivityFeedServerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImpl.java @@ -1,4 +1,4 @@ -package com.diffusiondata.pretend.example.activity.feed.service.impl; +package com.diffusiondata.pretend.example.sportsactivity.feed.service.impl; import static java.util.Collections.unmodifiableCollection; import static java.util.Collections.unmodifiableMap; @@ -19,35 +19,35 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; -import com.diffusiondata.pretend.example.activity.feed.service.ActivityFeedServer; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; +import com.diffusiondata.pretend.example.sportsactivity.feed.service.SportsActivityFeedServer; import net.datafaker.Faker; import net.jcip.annotations.GuardedBy; import net.jcip.annotations.ThreadSafe; @ThreadSafe -public final class PretendActivityFeedServerImpl - implements ActivityFeedServer, Runnable { +public final class PretendSportsActivityFeedServerImpl + implements SportsActivityFeedServer, Runnable { private static final Logger LOG = - LoggerFactory.getLogger(PretendActivityFeedServerImpl.class); + LoggerFactory.getLogger(PretendSportsActivityFeedServerImpl.class); private final Random random = new Random(); @GuardedBy("this") - private final Map listeners = + private final Map listeners = new HashMap<>(); - private final ConcurrentMap cachedLatestActivities = + private final ConcurrentMap cachedLatestActivities = new ConcurrentHashMap<>(); - private final Supplier activityGeneratorSupplier; + private final Supplier activityGeneratorSupplier; private final int maxSleepMillisBetweenActivityGeneration; - private PretendActivityFeedServerImpl( - Supplier activityGeneratorSupplier, + private PretendSportsActivityFeedServerImpl( + Supplier activityGeneratorSupplier, int maxSleepMillisBetweenActivityGeneration) { this.activityGeneratorSupplier = activityGeneratorSupplier; @@ -57,13 +57,13 @@ private PretendActivityFeedServerImpl( @Override public synchronized String registerClientListener( - ActivityFeedListener activityFeedListener) { + SportsActivityFeedListener sportsActivityFeedListener) { - requireNonNull(activityFeedListener, "activityFeedListener"); + requireNonNull(sportsActivityFeedListener, "activityFeedListener"); final String listenerIdentifier = UUID.randomUUID().toString(); - listeners.put(listenerIdentifier, activityFeedListener); + listeners.put(listenerIdentifier, sportsActivityFeedListener); LOG.info("Registered client listener: '{}'", listenerIdentifier); @@ -91,7 +91,7 @@ public synchronized boolean unregisterClientListener( } @Override - public Collection getLatestActivities() { + public Collection getLatestSportsActivities() { return unmodifiableCollection(cachedLatestActivities.values()); } @@ -112,7 +112,7 @@ public void run() { /** * package for tests. */ - Map getListeners() { + Map getListeners() { return unmodifiableMap(listeners); } @@ -122,9 +122,9 @@ Map getListeners() { void runOnce() throws InterruptedException { - final Activity activity = activityGeneratorSupplier.get(); + final SportsActivity sportsActivity = activityGeneratorSupplier.get(); - internalUpdateStateAndListeners(activity); + internalUpdateStateAndListeners(sportsActivity); final int sleepDurationMillis = random.nextInt(maxSleepMillisBetweenActivityGeneration); @@ -135,20 +135,20 @@ void runOnce() /** * package for tests. */ - Map getCachedLatestActivities() { + Map getCachedSportsLatestActivities() { return cachedLatestActivities; } /** * package for tests. */ - void internalUpdateStateAndListeners(Activity activity) { - cachedLatestActivities.put(activity.getSport(), activity); + void internalUpdateStateAndListeners(SportsActivity sportsActivity) { + cachedLatestActivities.put(sportsActivity.getSport(), sportsActivity); listeners.values() .forEach(listener -> { try { - listener.onMessage(activity); + listener.onMessage(sportsActivity); } catch (Exception e) { LOG.error("Exception invoking listener on message", e); @@ -156,9 +156,9 @@ void internalUpdateStateAndListeners(Activity activity) { }); } - public static ActivityFeedServer createAndStartActivityFeedServer() { - final Supplier activityGeneratorSupplier = - new ActivityGeneratorSupplier(new Faker()); + public static SportsActivityFeedServer createAndStartActivityFeedServer() { + final Supplier activityGeneratorSupplier = + new RandomSportsActivityGeneratorSupplier(new Faker()); return createAndStartActivityFeedServer( Executors.newSingleThreadExecutor(r -> { @@ -175,13 +175,13 @@ public static ActivityFeedServer createAndStartActivityFeedServer() { /** * package for tests. */ - static ActivityFeedServer createAndStartActivityFeedServer( + static SportsActivityFeedServer createAndStartActivityFeedServer( ExecutorService executorService, - Supplier activityGeneratorSupplier, + Supplier activityGeneratorSupplier, int maxSleepMillisBetweenActivityGeneration) { - final PretendActivityFeedServerImpl server = - new PretendActivityFeedServerImpl( + final PretendSportsActivityFeedServerImpl server = + new PretendSportsActivityFeedServerImpl( activityGeneratorSupplier, maxSleepMillisBetweenActivityGeneration); diff --git a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplier.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivityGeneratorSupplier.java similarity index 78% rename from activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplier.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivityGeneratorSupplier.java index 05c4101..f49d59b 100644 --- a/activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/activity/feed/service/impl/ActivityGeneratorSupplier.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivityGeneratorSupplier.java @@ -1,4 +1,4 @@ -package com.diffusiondata.pretend.example.activity.feed.service.impl; +package com.diffusiondata.pretend.example.sportsactivity.feed.service.impl; import static java.util.concurrent.TimeUnit.DAYS; @@ -6,29 +6,29 @@ import java.util.concurrent.TimeUnit; import java.util.function.Supplier; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; import net.datafaker.Faker; import net.jcip.annotations.ThreadSafe; @ThreadSafe -public final class ActivityGeneratorSupplier - implements Supplier { +public final class RandomSportsActivityGeneratorSupplier + implements Supplier { private final Faker faker; - public ActivityGeneratorSupplier(Faker faker) { + public RandomSportsActivityGeneratorSupplier(Faker faker) { this.faker = faker; } @Override - public Activity get() { + public SportsActivity get() { final String sport = faker.olympicSport().summerOlympics(); final String country = faker.country().name(); final String winner = faker.name().fullName(); final Instant dateOfActivity = timeAndDatePast(1, DAYS); - return new Activity( + return new SportsActivity( sport, country, winner, diff --git a/activity-feed-adapter/src/main/resources/configuration.json b/sports-activity-feed-adapter/src/main/resources/configuration.json similarity index 60% rename from activity-feed-adapter/src/main/resources/configuration.json rename to sports-activity-feed-adapter/src/main/resources/configuration.json index 87e8f31..d89a0f1 100644 --- a/activity-feed-adapter/src/main/resources/configuration.json +++ b/sports-activity-feed-adapter/src/main/resources/configuration.json @@ -3,29 +3,12 @@ "framework-version": 1, "application-version": 1, "diffusion": { - "url": "ws://localhost:8080", + "url": "ws://localhost:18080", "principal": "admin", "password": "password", "reconnectIntervalMs": 5000 }, "services": [ - { - "serviceName": "streaming-activity-feed-service-1", - "serviceType": "streaming-activity-feed-service", - "config": { - "framework": { - "topicProperties": { - "topicType": "JSON", - "persistencePolicy": "SESSION", - "publishValuesOnly": false, - "dontRetainValue": false - } - }, - "application": { - "topicPrefix": "activity/feed/stream" - } - } - }, { "serviceName": "polling-activity-feed-service-1", "serviceType": "polling-activity-feed-service", diff --git a/activity-feed-adapter/src/main/resources/log4j2.xml b/sports-activity-feed-adapter/src/main/resources/log4j2.xml similarity index 100% rename from activity-feed-adapter/src/main/resources/log4j2.xml rename to sports-activity-feed-adapter/src/main/resources/log4j2.xml diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtilsTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtilsTest.java similarity index 100% rename from activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtilsTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/common/jackson/ObjectMapperUtilsTest.java diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/RunnerTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/RunnerTest.java similarity index 87% rename from activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/RunnerTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/RunnerTest.java index 142b649..d1c0ae0 100644 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/RunnerTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/RunnerTest.java @@ -1,4 +1,4 @@ -package com.diffusiondata.gateway.example.activity.feed; +package com.diffusiondata.gateway.example.sportsactivity.feed; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsNull.notNullValue; diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityJsonTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java similarity index 75% rename from activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityJsonTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java index 24af116..6c889ce 100644 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityJsonTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java @@ -1,6 +1,6 @@ -package com.diffusiondata.gateway.example.activity.feed; +package com.diffusiondata.gateway.example.sportsactivity.feed; -import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.StringContains.containsString; @@ -9,7 +9,7 @@ import com.diffusiondata.gateway.example.common.jackson.ObjectMapperUtils; import com.fasterxml.jackson.databind.ObjectMapper; -class ActivityJsonTest { +class SportsActivityJsonTest { private final ObjectMapper objectMapper = ObjectMapperUtils.createAndConfigureObjectMapper(); diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplicationTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java similarity index 81% rename from activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplicationTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java index a753a03..8881d3b 100644 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedGatewayApplicationTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java @@ -1,9 +1,9 @@ -package com.diffusiondata.gateway.example.activity.feed; +package com.diffusiondata.gateway.example.sportsactivity.feed; -import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedGatewayApplication.APPLICATION_TYPE; -import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedGatewayApplication.POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME; -import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedGatewayApplication.STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME; -import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.APPLICATION_TYPE; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize; import static org.hamcrest.core.IsEqual.equalTo; @@ -34,13 +34,13 @@ import com.diffusiondata.gateway.framework.StateHandler; import com.diffusiondata.gateway.framework.StreamingSourceHandler; import com.diffusiondata.gateway.framework.exceptions.InvalidConfigurationException; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) -class ActivityFeedGatewayApplicationTest { +class SportsSportsActivityFeedGatewayApplicationTest { @Mock - private ActivityFeedClient activityFeedClientMock; + private SportsActivityFeedClient sportsActivityFeedClientMock; @Mock private ObjectMapper objectMapperMock; @@ -61,15 +61,15 @@ class ActivityFeedGatewayApplicationTest { @BeforeEach void beforeEachTest() { - application = new ActivityFeedGatewayApplication( - activityFeedClientMock, + application = new SportsActivityFeedGatewayApplication( + sportsActivityFeedClientMock, objectMapperMock); } @AfterEach void afterEachTest() { verifyNoMoreInteractions( - activityFeedClientMock, + sportsActivityFeedClientMock, objectMapperMock, serviceDefinitionMock, serviceTypeMock, @@ -115,7 +115,7 @@ void testAddStreamingSourceWhenServiceTypeExists() assertThat(handler, notNullValue()); assertThat(handler, - instanceOf(ActivityFeedListenerStreamingSourceHandlerImpl.class)); + instanceOf(SportsActivityFeedListenerStreamingSourceHandlerImpl.class)); } @Test @@ -155,7 +155,7 @@ void testAddPollingSourceWhenServiceTypeExists() assertThat(handler, notNullValue()); assertThat(handler, - instanceOf(ActivityFeedSnapshotPollingSourceHandlerImpl.class)); + instanceOf(SportsActivityFeedSnapshotPollingSourceHandlerImpl.class)); } @Test diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java similarity index 69% rename from activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java index c47e4ea..8b07964 100644 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedListenerStreamingSourceHandlerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java @@ -1,7 +1,7 @@ -package com.diffusiondata.gateway.example.activity.feed; +package com.diffusiondata.gateway.example.sportsactivity.feed; -import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; -import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsEqual.equalTo; import static org.hamcrest.core.IsNull.notNullValue; @@ -30,16 +30,16 @@ import com.diffusiondata.gateway.framework.StreamingSourceHandler; import com.diffusiondata.gateway.framework.exceptions.DiffusionClientException; import com.diffusiondata.gateway.util.Util; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedListener; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) -class ActivityFeedListenerStreamingSourceHandlerImplTest { +class SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest { @Mock - private ActivityFeedClient activityFeedClientMock; + private SportsActivityFeedClient sportsActivityFeedClientMock; @Mock private ServiceDefinition serviceDefinitionMock; @@ -60,15 +60,15 @@ void beforeEachTest() { when(serviceDefinitionMock.getParameters()) .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); - handler = new ActivityFeedListenerStreamingSourceHandlerImpl( - activityFeedClientMock, + handler = new SportsActivityFeedListenerStreamingSourceHandlerImpl( + sportsActivityFeedClientMock, serviceDefinitionMock, publisherMock, stateHandlerMock, objectMapperMock); final String topicPrefix = - ((ActivityFeedListenerStreamingSourceHandlerImpl) handler) + ((SportsActivityFeedListenerStreamingSourceHandlerImpl) handler) .getTopicPrefix(); assertThat(topicPrefix, notNullValue()); @@ -78,7 +78,7 @@ void beforeEachTest() { @AfterEach void afterEachTest() { verifyNoMoreInteractions( - activityFeedClientMock, + sportsActivityFeedClientMock, serviceDefinitionMock, publisherMock, stateHandlerMock, @@ -90,60 +90,60 @@ void afterEachTest() { void testOnMessageWhenServiceStateIsActive() throws Exception { - final Activity activity = createPopulatedActivity(); + final SportsActivity sportsActivity = createPopulatedActivity(); final String expectedTopicPath = DEFAULT_STREAMING_TOPIC_PREFIX + "/" - + activity.getSport(); + + sportsActivity.getSport(); final String jsonAsString = "{}"; when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(objectMapperMock.writeValueAsString(activity)) + when(objectMapperMock.writeValueAsString(sportsActivity)) .thenReturn(jsonAsString); when(publisherMock.publish(expectedTopicPath, jsonAsString)) .thenReturn(CompletableFuture.completedFuture(null)); - ((ActivityFeedListener) handler).onMessage(activity); + ((SportsActivityFeedListener) handler).onMessage(sportsActivity); } @Test void testOnMessageWhenServiceStateIsActiveAndPublishExceptionIsThrown() throws Exception { - final Activity activity = createPopulatedActivity(); + final SportsActivity sportsActivity = createPopulatedActivity(); final String topicPath = DEFAULT_STREAMING_TOPIC_PREFIX + "/" + - activity.getSport(); + sportsActivity.getSport(); final String jsonAsString = "{}"; when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(objectMapperMock.writeValueAsString(activity)) + when(objectMapperMock.writeValueAsString(sportsActivity)) .thenReturn(jsonAsString); when(publisherMock.publish(topicPath, jsonAsString)) .thenReturn(Util.getCfWithException( new DiffusionClientException("ignore this is a test"))); - ((ActivityFeedListener) handler).onMessage(activity); + ((SportsActivityFeedListener) handler).onMessage(sportsActivity); } @Test void testOnMessageWhenServiceStateIsActiveAndCheckedExceptionIsThrown() throws Exception { - final Activity activity = createPopulatedActivity(); + final SportsActivity sportsActivity = createPopulatedActivity(); when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); doThrow(JsonProcessingException.class).when(objectMapperMock) - .writeValueAsString(activity); + .writeValueAsString(sportsActivity); - ((ActivityFeedListener) handler).onMessage(activity); + ((SportsActivityFeedListener) handler).onMessage(sportsActivity); } @Test @@ -151,7 +151,7 @@ void testOnMessageWhenServiceStateIsNotActive() { when(stateHandlerMock.getState()) .thenReturn(ServiceState.PAUSED); - ((ActivityFeedListener) handler).onMessage(createPopulatedActivity()); + ((SportsActivityFeedListener) handler).onMessage(createPopulatedActivity()); } @Test @@ -165,7 +165,7 @@ void testStart() { void testStop() { final String listenerIdentifier = invokeStart(); - when(activityFeedClientMock.unregisterListener(listenerIdentifier)) + when(sportsActivityFeedClientMock.unregisterListener(listenerIdentifier)) .thenReturn(true); final CompletableFuture cf = handler.stop(); @@ -179,7 +179,7 @@ void testStop() { void testPause() { final String listenerIdentifier = invokeStart(); - when(activityFeedClientMock.unregisterListener(listenerIdentifier)) + when(sportsActivityFeedClientMock.unregisterListener(listenerIdentifier)) .thenReturn(true); final CompletableFuture cf = handler.pause(PauseReason.REQUESTED); @@ -193,9 +193,9 @@ void testPause() { void testResume() { final String listenerIdentifier = "listener-identifier"; - final ActivityFeedListener listener = (ActivityFeedListener) handler; + final SportsActivityFeedListener listener = (SportsActivityFeedListener) handler; - when(activityFeedClientMock.registerListener(listener)) + when(sportsActivityFeedClientMock.registerListener(listener)) .thenReturn(listenerIdentifier); final CompletableFuture cf = handler.resume(ResumeReason.REQUESTED); @@ -207,9 +207,9 @@ void testResume() { private String invokeStart() { final String listenerIdentifier = "listener-identifier"; - final ActivityFeedListener listener = (ActivityFeedListener) handler; + final SportsActivityFeedListener listener = (SportsActivityFeedListener) handler; - when(activityFeedClientMock.registerListener(listener)) + when(sportsActivityFeedClientMock.registerListener(listener)) .thenReturn(listenerIdentifier); final CompletableFuture cf = handler.start(); diff --git a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java similarity index 81% rename from activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImplTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java index 2fa69ee..4b776c5 100644 --- a/activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/activity/feed/ActivityFeedSnapshotPollingSourceHandlerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java @@ -1,7 +1,7 @@ -package com.diffusiondata.gateway.example.activity.feed; +package com.diffusiondata.gateway.example.sportsactivity.feed; -import static com.diffusiondata.gateway.example.activity.feed.ActivityFeedSnapshotPollingSourceHandlerImpl.DEFAULT_POLLING_TOPIC_PATH; -import static com.diffusiondata.pretend.example.activity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedSnapshotPollingSourceHandlerImpl.DEFAULT_POLLING_TOPIC_PATH; +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsEqual.equalTo; import static org.hamcrest.core.IsInstanceOf.instanceOf; @@ -34,15 +34,15 @@ import com.diffusiondata.gateway.framework.StateHandler; import com.diffusiondata.gateway.framework.exceptions.DiffusionClientException; import com.diffusiondata.gateway.util.Util; -import com.diffusiondata.pretend.example.activity.feed.client.ActivityFeedClient; -import com.diffusiondata.pretend.example.activity.feed.model.Activity; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) -class ActivityFeedSnapshotPollingSourceHandlerImplTest { +class SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest { @Mock - private ActivityFeedClient activityFeedClientMock; + private SportsActivityFeedClient sportsActivityFeedClientMock; @Mock private ServiceDefinition serviceDefinitionMock; @@ -63,15 +63,15 @@ void beforeEachTest() { when(serviceDefinitionMock.getParameters()) .thenReturn(Map.of("topicPrefix", DEFAULT_POLLING_TOPIC_PATH)); - handler = new ActivityFeedSnapshotPollingSourceHandlerImpl( - activityFeedClientMock, + handler = new SportsActivityFeedSnapshotPollingSourceHandlerImpl( + sportsActivityFeedClientMock, serviceDefinitionMock, publisherMock, stateHandlerMock, objectMapperMock); final String topicPrefix = - ((ActivityFeedSnapshotPollingSourceHandlerImpl) handler) + ((SportsActivityFeedSnapshotPollingSourceHandlerImpl) handler) .getTopicPath(); assertThat(topicPrefix, notNullValue()); @@ -81,7 +81,7 @@ void beforeEachTest() { @AfterEach void afterEachTest() { verifyNoMoreInteractions( - activityFeedClientMock, + sportsActivityFeedClientMock, serviceDefinitionMock, publisherMock, stateHandlerMock, @@ -91,12 +91,12 @@ void afterEachTest() { @Test void testPollWhenServiceStateIsActiveAndLatestActivitiesIsEmpty() { - final Collection activities = List.of(); + final Collection activities = List.of(); when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(activityFeedClientMock.getLatestActivities()) + when(sportsActivityFeedClientMock.getLatestActivities()) .thenReturn(activities); final CompletableFuture cf = handler.poll(); @@ -109,7 +109,7 @@ void testPollWhenServiceStateIsActiveAndLatestActivitiesIsEmpty() { void testPollWhenServiceStateIsActiveAndLatestActivitiesHasItems() throws Exception { - final Collection activities = + final Collection activities = List.of(createPopulatedActivity()); final String jsonAsString = "{}"; @@ -117,7 +117,7 @@ void testPollWhenServiceStateIsActiveAndLatestActivitiesHasItems() when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(activityFeedClientMock.getLatestActivities()) + when(sportsActivityFeedClientMock.getLatestActivities()) .thenReturn(activities); when(objectMapperMock.writeValueAsString(activities)) @@ -136,7 +136,7 @@ void testPollWhenServiceStateIsActiveAndLatestActivitiesHasItems() void testPollWhenServiceStateIsActiveAndPublishExceptionIsThrown() throws Exception { - final Collection activities = + final Collection activities = List.of(createPopulatedActivity()); final String jsonAsString = "{}"; @@ -144,7 +144,7 @@ void testPollWhenServiceStateIsActiveAndPublishExceptionIsThrown() when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(activityFeedClientMock.getLatestActivities()) + when(sportsActivityFeedClientMock.getLatestActivities()) .thenReturn(activities); when(objectMapperMock.writeValueAsString(activities)) @@ -166,13 +166,13 @@ void testPollWhenServiceStateIsActiveAndPublishExceptionIsThrown() void testPollWhenServiceStateIsActiveAndCheckedExceptionIsThrown() throws Exception { - final Collection activities = + final Collection activities = List.of(createPopulatedActivity()); when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(activityFeedClientMock.getLatestActivities()) + when(sportsActivityFeedClientMock.getLatestActivities()) .thenReturn(activities); doThrow(JsonProcessingException.class).when(objectMapperMock) diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java new file mode 100644 index 0000000..f9400da --- /dev/null +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java @@ -0,0 +1,81 @@ +package com.diffusiondata.pretend.example.sportsactivity.feed.client.impl; + +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +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 com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; +import com.diffusiondata.pretend.example.sportsactivity.feed.service.SportsActivityFeedServer; + +@ExtendWith(MockitoExtension.class) +class SportsSportsActivityFeedClientImplTest { + @Mock + private SportsActivityFeedServer sportsActivityFeedServerMock; + + private SportsActivityFeedClient sportsActivityFeedClient; + + @BeforeEach + void beforeEachTest() { + sportsActivityFeedClient = + SportsActivityFeedClientImpl.connectToActivityFeedServer(sportsActivityFeedServerMock); + } + + @AfterEach + void afterEachTest() { + verifyNoMoreInteractions(sportsActivityFeedServerMock); + } + + @Test + void testRegisterListener() { + final String expectedIdentifier = "listener-id-1"; + final SportsActivityFeedListener listener = mock(SportsActivityFeedListener.class); + + when(sportsActivityFeedServerMock.registerClientListener(listener)) + .thenReturn(expectedIdentifier); + + final String listenerIdentifier = + sportsActivityFeedClient.registerListener(listener); + + assertThat(listenerIdentifier, equalTo(expectedIdentifier)); + } + + @Test + void testUnregisterListener() { + when(sportsActivityFeedServerMock.unregisterClientListener("abc")) + .thenReturn(true); + + final boolean result = + sportsActivityFeedClient.unregisterListener("abc"); + + assertThat(result, equalTo(true)); + } + + @Test + void testGetActivityFeed() { + final Collection expectedActivities = + List.of(createPopulatedActivity()); + + when(sportsActivityFeedServerMock.getLatestSportsActivities()) + .thenReturn(expectedActivities); + + final Collection latestActivities = + sportsActivityFeedClient.getLatestActivities(); + + assertThat(latestActivities, equalTo(expectedActivities)); + } +} diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/ActivityTestUtils.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/ActivityTestUtils.java new file mode 100644 index 0000000..a82b33f --- /dev/null +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/ActivityTestUtils.java @@ -0,0 +1,17 @@ +package com.diffusiondata.pretend.example.sportsactivity.feed.model; + +import java.time.Instant; + +public final class ActivityTestUtils { + private ActivityTestUtils() { + // Private constructor to prevent creation + } + + public static SportsActivity createPopulatedActivity() { + return createPopulatedActivity("some-sport"); + } + + public static SportsActivity createPopulatedActivity(String sport) { + return new SportsActivity(sport, "c", "w", Instant.now()); + } +} diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java new file mode 100644 index 0000000..37508a3 --- /dev/null +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java @@ -0,0 +1,200 @@ +package com.diffusiondata.pretend.example.sportsactivity.feed.service.impl; + +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.collection.IsEmptyIterable.emptyIterable; +import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize; +import static org.hamcrest.collection.IsMapContaining.hasKey; +import static org.hamcrest.collection.IsMapWithSize.aMapWithSize; +import static org.hamcrest.collection.IsMapWithSize.anEmptyMap; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.IsSame.sameInstance; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; +import com.diffusiondata.pretend.example.sportsactivity.feed.service.SportsActivityFeedServer; + +@ExtendWith(MockitoExtension.class) +class PretendSportsSportsActivityFeedServerImplTest { + private static final String SPORT = "tennis"; + + @Mock + private ExecutorService executorServiceMock; + + @Mock + private RandomSportsActivityGeneratorSupplier + randomSportsActivityGeneratorSupplierMock; + + @Mock + private SportsActivityFeedListener sportsActivityFeedListenerMock; + + private SportsActivityFeedServer sportsActivityFeedServer; + + @BeforeEach + void beforeEachTest() { + when(executorServiceMock.submit(any(Runnable.class))) + .thenReturn(null); + + sportsActivityFeedServer = + PretendSportsActivityFeedServerImpl + .createAndStartActivityFeedServer( + executorServiceMock, + randomSportsActivityGeneratorSupplierMock, + 0); + } + + @AfterEach + void afterEachTest() { + verifyNoMoreInteractions( + executorServiceMock, + randomSportsActivityGeneratorSupplierMock, + sportsActivityFeedListenerMock + ); + } + + @Test + @Order(10) + void testRegisterClientListener() { + final String listenerIdentifier = + sportsActivityFeedServer.registerClientListener( + sportsActivityFeedListenerMock); + + assertThat(listenerIdentifier, notNullValue()); + + final Map listeners = + getImpl().getListeners(); + + assertThat(listeners, aMapWithSize(1)); + assertThat(listeners, hasKey(listenerIdentifier)); + assertThat(listeners.get(listenerIdentifier), + sameInstance(sportsActivityFeedListenerMock)); + } + + @Test + @Order(20) + void testUnregisterClientListenerWhenExists() { + final String listenerIdentifier = + sportsActivityFeedServer.registerClientListener( + sportsActivityFeedListenerMock); + + final boolean result = + sportsActivityFeedServer.unregisterClientListener( + listenerIdentifier); + + assertThat(result, equalTo(true)); + + final Map listeners = + getImpl().getListeners(); + + assertThat(listeners, anEmptyMap()); + } + + @Test + void testUnregisterClientListenerWhenDoesNotExists() { + final boolean result = + sportsActivityFeedServer.unregisterClientListener( + "unknown-^^-listener-**-identifier"); + + assertThat(result, equalTo(false)); + } + + @Test + void testGetLatestSportsActivitiesWhenNoneGenerated() { + final Collection latestActivities = + sportsActivityFeedServer.getLatestSportsActivities(); + + assertThat(latestActivities, emptyIterable()); + } + + @Test + @Order(30) + void testInternalUpdateStateAndListenersWhenListenerRegistered() { + final SportsActivity sportsActivity = createPopulatedActivity(SPORT); + + doNothing().when(sportsActivityFeedListenerMock) + .onMessage(sportsActivity); + + sportsActivityFeedServer.registerClientListener( + sportsActivityFeedListenerMock); + + getImpl().internalUpdateStateAndListeners(sportsActivity); + + checkCachedLatestActivitiesAsExpected(); + } + + @Test + @Order(33) + void testInternalUpdateStateAndListenersWhenNoListenersRegistered() { + final SportsActivity sportsActivity = createPopulatedActivity(SPORT); + + getImpl().internalUpdateStateAndListeners(sportsActivity); + + checkCachedLatestActivitiesAsExpected(); + } + + @Test + @Order(36) + void testInternalUpdateStateAndListenersWhenListenerRegisterAndExceptions() { + final SportsActivity sportsActivity = createPopulatedActivity(SPORT); + + doThrow(IllegalStateException.class) + .when(sportsActivityFeedListenerMock) + .onMessage(sportsActivity); + + sportsActivityFeedServer.registerClientListener( + sportsActivityFeedListenerMock); + + getImpl().internalUpdateStateAndListeners(sportsActivity); + + checkCachedLatestActivitiesAsExpected(); + } + + @Test + @Order(40) + void testGetLatestSportsActivitiesWhenSomeGenerated() + throws Exception { + + final SportsActivity sportsActivity = createPopulatedActivity(SPORT); + + when(randomSportsActivityGeneratorSupplierMock.get()) + .thenReturn(sportsActivity); + + getImpl().runOnce(); + + final Collection latestActivities = + sportsActivityFeedServer.getLatestSportsActivities(); + + assertThat(latestActivities, iterableWithSize(1)); + assertThat(latestActivities.iterator().next(), equalTo(sportsActivity)); + } + + private PretendSportsActivityFeedServerImpl getImpl() { + return (PretendSportsActivityFeedServerImpl) sportsActivityFeedServer; + } + + private void checkCachedLatestActivitiesAsExpected() { + final Map cachedLatestSportsActivities = + getImpl().getCachedSportsLatestActivities(); + + assertThat(cachedLatestSportsActivities, aMapWithSize(1)); + assertThat(cachedLatestSportsActivities, hasKey(SPORT)); + } +} diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivityGeneratorSupplierTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivityGeneratorSupplierTest.java new file mode 100644 index 0000000..a466a1b --- /dev/null +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivityGeneratorSupplierTest.java @@ -0,0 +1,42 @@ +package com.diffusiondata.pretend.example.sportsactivity.feed.service.impl; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.lessThan; +import static org.hamcrest.core.IsNull.notNullValue; + +import java.time.Instant; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; + +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; + +import net.datafaker.Faker; + +class SportsRandomSportsActivityGeneratorSupplierTest { + private final Supplier supplier = + new RandomSportsActivityGeneratorSupplier(new Faker()); + + @Test + void testGet() { + final SportsActivity sportsActivity = supplier.get(); + + assertThat(sportsActivity, notNullValue()); + assertThat(sportsActivity.getSport(), notNullValue()); + assertThat(sportsActivity.getCountry(), notNullValue()); + assertThat(sportsActivity.getWinner(), notNullValue()); + assertThat(sportsActivity.getDateOfActivity(), notNullValue()); + } + + @Test + void testTimeAndDatePast() { + final RandomSportsActivityGeneratorSupplier impl = + (RandomSportsActivityGeneratorSupplier) supplier; + + final Instant pastDate = impl.timeAndDatePast(2, MINUTES); + + assertThat(pastDate.toEpochMilli(), + lessThan(System.currentTimeMillis())); + } +} From 75aff5a158d81424018dca5e88f1fc434c7fb473 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sat, 23 Nov 2024 13:13:23 +0000 Subject: [PATCH 16/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/pom.xml | 2 +- .../SportsActivityFeedGatewayApplication.java | 25 +++++++------- ...eedListenerStreamingSourceHandlerImpl.java | 25 ++++++++------ ...yFeedSnapshotPollingSourceHandlerImpl.java | 14 ++++---- .../feed/client/SportsActivityFeedClient.java | 2 +- .../impl/SportsActivityFeedClientImpl.java | 2 +- .../service/SportsActivityFeedServer.java | 3 +- .../PretendSportsActivityFeedServerImpl.java | 33 ++++++++++--------- ...java => RandomSportsActivitySupplier.java} | 4 +-- .../src/main/resources/configuration.json | 27 ++++++++++++--- .../src/main/resources/log4j2.xml | 2 +- ...rtsActivityFeedGatewayApplicationTest.java | 8 ++--- ...dSnapshotPollingSourceHandlerImplTest.java | 8 ++--- ...portsSportsActivityFeedClientImplTest.java | 2 +- ...portsSportsActivityFeedServerImplTest.java | 10 +++--- ...ortsRandomSportsActivitySupplierTest.java} | 8 ++--- 16 files changed, 101 insertions(+), 74 deletions(-) rename sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/{RandomSportsActivityGeneratorSupplier.java => RandomSportsActivitySupplier.java} (93%) rename sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/{SportsRandomSportsActivityGeneratorSupplierTest.java => SportsRandomSportsActivitySupplierTest.java} (83%) diff --git a/sports-activity-feed-adapter/pom.xml b/sports-activity-feed-adapter/pom.xml index a07f3b8..ddff6f9 100644 --- a/sports-activity-feed-adapter/pom.xml +++ b/sports-activity-feed-adapter/pom.xml @@ -79,7 +79,7 @@ - com.diffusiondata.gateway.example.sportsActivity.feed.Runner + com.diffusiondata.gateway.example.sportsactivity.feed.Runner diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java index cbbb856..99528de 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java @@ -27,13 +27,13 @@ public final class SportsActivityFeedGatewayApplication implements GatewayApplication { static final String APPLICATION_TYPE = - "activity-feed-application"; + "sports-activity-feed-application"; - static final String STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME = - "streaming-activity-feed-service"; + static final String STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME = + "streaming-sports-activity-feed-service"; - static final String POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME = - "polling-activity-feed-service"; + static final String POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME = + "polling-sports-activity-feed-service"; private static final Logger LOG = LoggerFactory.getLogger(SportsActivityFeedGatewayApplication.class); @@ -46,7 +46,8 @@ public SportsActivityFeedGatewayApplication( ObjectMapper objectMapper) { this.sportsActivityFeedClient = - requireNonNull(sportsActivityFeedClient, "activityFeedClient"); + requireNonNull(sportsActivityFeedClient, + "sportsActivityFeedClient"); this.objectMapper = requireNonNull(objectMapper, "objectMapper"); @@ -58,14 +59,14 @@ public ApplicationDetails getApplicationDetails() return DiffusionGatewayFramework.newApplicationDetailsBuilder() .addServiceType( - STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME, + STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME, ServiceMode.STREAMING_SOURCE, - "Streaming activity feed", + "Streaming sports activity feed", null) .addServiceType( - POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME, + POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME, ServiceMode.POLLING_SOURCE, - "Polled activity feed snapshot", + "Polled sports activity feed snapshot", null ) .build(APPLICATION_TYPE, 1); @@ -81,7 +82,7 @@ public StreamingSourceHandler addStreamingSource( final String serviceType = serviceDefinition.getServiceType().getName(); - if (STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { + if (STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { return new SportsActivityFeedListenerStreamingSourceHandlerImpl( sportsActivityFeedClient, serviceDefinition, @@ -104,7 +105,7 @@ public PollingSourceHandler addPollingSource( final String serviceType = serviceDefinition.getServiceType().getName(); - if (POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { + if (POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { return new SportsActivityFeedSnapshotPollingSourceHandlerImpl( sportsActivityFeedClient, serviceDefinition, diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java index b9c6d23..2d96d2a 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java @@ -27,10 +27,11 @@ public final class SportsActivityFeedListenerStreamingSourceHandlerImpl StreamingSourceHandler { static final String DEFAULT_STREAMING_TOPIC_PREFIX = - "streaming/activity/feed"; + "default/sports/activity/feed/stream"; private static final Logger LOG = - LoggerFactory.getLogger(SportsActivityFeedListenerStreamingSourceHandlerImpl.class); + LoggerFactory.getLogger( + SportsActivityFeedListenerStreamingSourceHandlerImpl.class); private final SportsActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; @@ -48,7 +49,8 @@ public SportsActivityFeedListenerStreamingSourceHandlerImpl( ObjectMapper objectMapper) { this.sportsActivityFeedClient = - requireNonNull(sportsActivityFeedClient, "activityFeedClient"); + requireNonNull(sportsActivityFeedClient, + "sportsActivityFeedClient"); this.publisher = requireNonNull(publisher, "publisher"); this.stateHandler = requireNonNull(stateHandler, "stateHandler"); @@ -62,12 +64,15 @@ public SportsActivityFeedListenerStreamingSourceHandlerImpl( @Override public void onMessage(SportsActivity sportsActivity) { - requireNonNull(sportsActivity, "activity"); + requireNonNull(sportsActivity, "sportsActivity"); if (stateHandler.getState().equals(ServiceState.ACTIVE)) { try { - final String topicPath = topicPrefix + "/" + sportsActivity.getSport(); - final String value = objectMapper.writeValueAsString(sportsActivity); + final String topicPath = topicPrefix + "/" + + sportsActivity.getSport(); + + final String value = + objectMapper.writeValueAsString(sportsActivity); publisher.publish(topicPath, value) .exceptionally(throwable -> { @@ -90,7 +95,7 @@ public CompletableFuture start() { listenerIdentifier = sportsActivityFeedClient.registerListener(this); - LOG.info("Started activity feed streaming handler"); + LOG.info("Started sports activity feed streaming handler"); return CompletableFuture.completedFuture(null); } @@ -99,7 +104,7 @@ public CompletableFuture start() { public CompletableFuture stop() { sportsActivityFeedClient.unregisterListener(listenerIdentifier); - LOG.info("Stopped activity feed streaming handler"); + LOG.info("Stopped sports activity feed streaming handler"); return CompletableFuture.completedFuture(null); } @@ -108,7 +113,7 @@ public CompletableFuture stop() { public CompletableFuture pause(PauseReason reason) { sportsActivityFeedClient.unregisterListener(listenerIdentifier); - LOG.info("Paused activity feed streaming handler"); + LOG.info("Paused sports activity feed streaming handler"); return CompletableFuture.completedFuture(null); } @@ -118,7 +123,7 @@ public CompletableFuture resume(ResumeReason reason) { listenerIdentifier = sportsActivityFeedClient.registerListener(this); - LOG.info("Resumed activity feed streaming handler"); + LOG.info("Resumed sports activity feed streaming handler"); return CompletableFuture.completedFuture(null); } diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java index 3d6539a..4f8b0ec 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java @@ -26,10 +26,11 @@ public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl implements PollingSourceHandler { static final String DEFAULT_POLLING_TOPIC_PATH = - "polling/activity/feed"; + "default/sports/activity/feed/snapshot"; private static final Logger LOG = - LoggerFactory.getLogger(SportsActivityFeedSnapshotPollingSourceHandlerImpl.class); + LoggerFactory.getLogger( + SportsActivityFeedSnapshotPollingSourceHandlerImpl.class); private final SportsActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; @@ -45,7 +46,8 @@ public SportsActivityFeedSnapshotPollingSourceHandlerImpl( ObjectMapper objectMapper) { this.sportsActivityFeedClient = - requireNonNull(sportsActivityFeedClient, "activityFeedClient"); + requireNonNull(sportsActivityFeedClient, + "sportActivityFeedClient"); this.publisher = requireNonNull(publisher, "publisher"); this.stateHandler = requireNonNull(stateHandler, "stateHandler"); @@ -68,7 +70,7 @@ public CompletableFuture poll() { } final Collection activities = - sportsActivityFeedClient.getLatestActivities(); + sportsActivityFeedClient.getLatestSportsActivities(); if (activities.isEmpty()) { pollCf.complete(null); @@ -101,14 +103,14 @@ public CompletableFuture poll() { @Override public CompletableFuture pause(PauseReason reason) { - LOG.info("Paused activity feed polling handler"); + LOG.info("Paused sports activity feed polling handler"); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture resume(ResumeReason reason) { - LOG.info("Resumed activity feed polling handler"); + LOG.info("Resumed sports activity feed polling handler"); return CompletableFuture.completedFuture(null); } diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedClient.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedClient.java index 7b69acd..0604152 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedClient.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/SportsActivityFeedClient.java @@ -10,5 +10,5 @@ String registerListener( boolean unregisterListener(String listenerIdentifier); - Collection getLatestActivities(); + Collection getLatestSportsActivities(); } diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImpl.java index d64dcd6..5b1ec9a 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImpl.java @@ -37,7 +37,7 @@ public boolean unregisterListener(String listenerIdentifier) { } @Override - public Collection getLatestActivities() { + public Collection getLatestSportsActivities() { return sportsActivityFeedServer.getLatestSportsActivities(); } diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/SportsActivityFeedServer.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/SportsActivityFeedServer.java index b19c31f..e71fb73 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/SportsActivityFeedServer.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/SportsActivityFeedServer.java @@ -6,7 +6,8 @@ import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; public interface SportsActivityFeedServer { - String registerClientListener(SportsActivityFeedListener sportsActivityFeedListener); + String registerClientListener( + SportsActivityFeedListener sportsActivityFeedListener); boolean unregisterClientListener(String listenerIdentifier); diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImpl.java index a2519d6..dccf337 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImpl.java @@ -40,17 +40,17 @@ public final class PretendSportsActivityFeedServerImpl private final Map listeners = new HashMap<>(); - private final ConcurrentMap cachedLatestActivities = - new ConcurrentHashMap<>(); + private final ConcurrentMap + cachedSportedLatestActivities = new ConcurrentHashMap<>(); - private final Supplier activityGeneratorSupplier; + private final Supplier sportsActivitySupplier; private final int maxSleepMillisBetweenActivityGeneration; private PretendSportsActivityFeedServerImpl( - Supplier activityGeneratorSupplier, + Supplier sportsActivitySupplier, int maxSleepMillisBetweenActivityGeneration) { - this.activityGeneratorSupplier = activityGeneratorSupplier; + this.sportsActivitySupplier = sportsActivitySupplier; this.maxSleepMillisBetweenActivityGeneration = Math.max(maxSleepMillisBetweenActivityGeneration, 1); } @@ -59,7 +59,8 @@ private PretendSportsActivityFeedServerImpl( public synchronized String registerClientListener( SportsActivityFeedListener sportsActivityFeedListener) { - requireNonNull(sportsActivityFeedListener, "activityFeedListener"); + requireNonNull(sportsActivityFeedListener, + "sportsActivityFeedListener"); final String listenerIdentifier = UUID.randomUUID().toString(); @@ -92,12 +93,12 @@ public synchronized boolean unregisterClientListener( @Override public Collection getLatestSportsActivities() { - return unmodifiableCollection(cachedLatestActivities.values()); + return unmodifiableCollection(cachedSportedLatestActivities.values()); } @Override public void run() { - LOG.info("Started activity feed server"); + LOG.info("Started sports activity feed server"); try { while (!Thread.currentThread().isInterrupted()) { @@ -122,7 +123,7 @@ Map getListeners() { void runOnce() throws InterruptedException { - final SportsActivity sportsActivity = activityGeneratorSupplier.get(); + final SportsActivity sportsActivity = sportsActivitySupplier.get(); internalUpdateStateAndListeners(sportsActivity); @@ -136,14 +137,14 @@ void runOnce() * package for tests. */ Map getCachedSportsLatestActivities() { - return cachedLatestActivities; + return cachedSportedLatestActivities; } /** * package for tests. */ void internalUpdateStateAndListeners(SportsActivity sportsActivity) { - cachedLatestActivities.put(sportsActivity.getSport(), sportsActivity); + cachedSportedLatestActivities.put(sportsActivity.getSport(), sportsActivity); listeners.values() .forEach(listener -> { @@ -157,8 +158,8 @@ void internalUpdateStateAndListeners(SportsActivity sportsActivity) { } public static SportsActivityFeedServer createAndStartActivityFeedServer() { - final Supplier activityGeneratorSupplier = - new RandomSportsActivityGeneratorSupplier(new Faker()); + final Supplier sportsActivitySupplier = + new RandomSportsActivitySupplier(new Faker()); return createAndStartActivityFeedServer( Executors.newSingleThreadExecutor(r -> { @@ -168,7 +169,7 @@ public static SportsActivityFeedServer createAndStartActivityFeedServer() { return t; }), - activityGeneratorSupplier, + sportsActivitySupplier, 250); } @@ -177,12 +178,12 @@ public static SportsActivityFeedServer createAndStartActivityFeedServer() { */ static SportsActivityFeedServer createAndStartActivityFeedServer( ExecutorService executorService, - Supplier activityGeneratorSupplier, + Supplier sportsActivitySupplier, int maxSleepMillisBetweenActivityGeneration) { final PretendSportsActivityFeedServerImpl server = new PretendSportsActivityFeedServerImpl( - activityGeneratorSupplier, + sportsActivitySupplier, maxSleepMillisBetweenActivityGeneration); executorService.submit(server); diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivityGeneratorSupplier.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivitySupplier.java similarity index 93% rename from sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivityGeneratorSupplier.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivitySupplier.java index f49d59b..9b5f2db 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivityGeneratorSupplier.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/RandomSportsActivitySupplier.java @@ -12,12 +12,12 @@ import net.jcip.annotations.ThreadSafe; @ThreadSafe -public final class RandomSportsActivityGeneratorSupplier +public final class RandomSportsActivitySupplier implements Supplier { private final Faker faker; - public RandomSportsActivityGeneratorSupplier(Faker faker) { + public RandomSportsActivitySupplier(Faker faker) { this.faker = faker; } diff --git a/sports-activity-feed-adapter/src/main/resources/configuration.json b/sports-activity-feed-adapter/src/main/resources/configuration.json index d89a0f1..c634b3c 100644 --- a/sports-activity-feed-adapter/src/main/resources/configuration.json +++ b/sports-activity-feed-adapter/src/main/resources/configuration.json @@ -1,17 +1,17 @@ { - "id": "activity-feed-adapter-1", + "id": "sports-activity-feed-adapter-1", "framework-version": 1, "application-version": 1, "diffusion": { - "url": "ws://localhost:18080", + "url": "ws://localhost:8080", "principal": "admin", "password": "password", "reconnectIntervalMs": 5000 }, "services": [ { - "serviceName": "polling-activity-feed-service-1", - "serviceType": "polling-activity-feed-service", + "serviceName": "polling-sports-activity-feed-service-1", + "serviceType": "polling-sports-activity-feed-service", "config": { "framework": { "pollIntervalMs": 4500, @@ -24,7 +24,24 @@ } }, "application": { - "topicPath": "activity/feed/snapshot" + "topicPath": "sports/activity/feed/snapshot" + } + } + }, + { + "serviceName": "streaming-sports-activity-feed-service-1", + "serviceType": "streaming-sports-activity-feed-service", + "config": { + "framework": { + "topicProperties": { + "topicType": "JSON", + "persistencePolicy": "SESSION", + "publishValuesOnly": false, + "dontRetainValue": false + } + }, + "application": { + "topicPrefix": "sports/activity/feed/stream" } } } diff --git a/sports-activity-feed-adapter/src/main/resources/log4j2.xml b/sports-activity-feed-adapter/src/main/resources/log4j2.xml index 458c02e..362e711 100644 --- a/sports-activity-feed-adapter/src/main/resources/log4j2.xml +++ b/sports-activity-feed-adapter/src/main/resources/log4j2.xml @@ -1,7 +1,7 @@ diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java index 8881d3b..b510e47 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java @@ -1,8 +1,8 @@ package com.diffusiondata.gateway.example.sportsactivity.feed; import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.APPLICATION_TYPE; -import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME; -import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME; import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize; @@ -102,7 +102,7 @@ void testAddStreamingSourceWhenServiceTypeExists() .thenReturn(serviceTypeMock); when(serviceTypeMock.getName()) - .thenReturn(STREAMING_ACTIVITY_FEED_SERVICE_TYPE_NAME); + .thenReturn(STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME); when(serviceDefinitionMock.getParameters()) .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); @@ -142,7 +142,7 @@ void testAddPollingSourceWhenServiceTypeExists() .thenReturn(serviceTypeMock); when(serviceTypeMock.getName()) - .thenReturn(POLLING_ACTIVITY_FEED_SERVICE_TYPE_NAME); + .thenReturn(POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME); when(serviceDefinitionMock.getParameters()) .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java index 4b776c5..f9fc9ac 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java @@ -96,7 +96,7 @@ void testPollWhenServiceStateIsActiveAndLatestActivitiesIsEmpty() { when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(sportsActivityFeedClientMock.getLatestActivities()) + when(sportsActivityFeedClientMock.getLatestSportsActivities()) .thenReturn(activities); final CompletableFuture cf = handler.poll(); @@ -117,7 +117,7 @@ void testPollWhenServiceStateIsActiveAndLatestActivitiesHasItems() when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(sportsActivityFeedClientMock.getLatestActivities()) + when(sportsActivityFeedClientMock.getLatestSportsActivities()) .thenReturn(activities); when(objectMapperMock.writeValueAsString(activities)) @@ -144,7 +144,7 @@ void testPollWhenServiceStateIsActiveAndPublishExceptionIsThrown() when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(sportsActivityFeedClientMock.getLatestActivities()) + when(sportsActivityFeedClientMock.getLatestSportsActivities()) .thenReturn(activities); when(objectMapperMock.writeValueAsString(activities)) @@ -172,7 +172,7 @@ void testPollWhenServiceStateIsActiveAndCheckedExceptionIsThrown() when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); - when(sportsActivityFeedClientMock.getLatestActivities()) + when(sportsActivityFeedClientMock.getLatestSportsActivities()) .thenReturn(activities); doThrow(JsonProcessingException.class).when(objectMapperMock) diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java index f9400da..07e9852 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java @@ -74,7 +74,7 @@ void testGetActivityFeed() { .thenReturn(expectedActivities); final Collection latestActivities = - sportsActivityFeedClient.getLatestActivities(); + sportsActivityFeedClient.getLatestSportsActivities(); assertThat(latestActivities, equalTo(expectedActivities)); } diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java index 37508a3..715e32f 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java @@ -40,8 +40,8 @@ class PretendSportsSportsActivityFeedServerImplTest { private ExecutorService executorServiceMock; @Mock - private RandomSportsActivityGeneratorSupplier - randomSportsActivityGeneratorSupplierMock; + private RandomSportsActivitySupplier + randomSportsActivitySupplierMock; @Mock private SportsActivityFeedListener sportsActivityFeedListenerMock; @@ -57,7 +57,7 @@ void beforeEachTest() { PretendSportsActivityFeedServerImpl .createAndStartActivityFeedServer( executorServiceMock, - randomSportsActivityGeneratorSupplierMock, + randomSportsActivitySupplierMock, 0); } @@ -65,7 +65,7 @@ void beforeEachTest() { void afterEachTest() { verifyNoMoreInteractions( executorServiceMock, - randomSportsActivityGeneratorSupplierMock, + randomSportsActivitySupplierMock, sportsActivityFeedListenerMock ); } @@ -174,7 +174,7 @@ void testGetLatestSportsActivitiesWhenSomeGenerated() final SportsActivity sportsActivity = createPopulatedActivity(SPORT); - when(randomSportsActivityGeneratorSupplierMock.get()) + when(randomSportsActivitySupplierMock.get()) .thenReturn(sportsActivity); getImpl().runOnce(); diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivityGeneratorSupplierTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivitySupplierTest.java similarity index 83% rename from sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivityGeneratorSupplierTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivitySupplierTest.java index a466a1b..9740494 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivityGeneratorSupplierTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/SportsRandomSportsActivitySupplierTest.java @@ -14,9 +14,9 @@ import net.datafaker.Faker; -class SportsRandomSportsActivityGeneratorSupplierTest { +class SportsRandomSportsActivitySupplierTest { private final Supplier supplier = - new RandomSportsActivityGeneratorSupplier(new Faker()); + new RandomSportsActivitySupplier(new Faker()); @Test void testGet() { @@ -31,8 +31,8 @@ void testGet() { @Test void testTimeAndDatePast() { - final RandomSportsActivityGeneratorSupplier impl = - (RandomSportsActivityGeneratorSupplier) supplier; + final RandomSportsActivitySupplier impl = + (RandomSportsActivitySupplier) supplier; final Instant pastDate = impl.timeAndDatePast(2, MINUTES); From 78f3b6ff954cfade0bbf737758281d552d6d8451 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sun, 24 Nov 2024 13:05:57 +0000 Subject: [PATCH 17/33] DOC-428: sports activity feed streaming and polling example. --- .../feed/SportsActivityJsonTest.java | 5 +++-- ...dListenerStreamingSourceHandlerImplTest.java | 11 ++++++----- ...eedSnapshotPollingSourceHandlerImplTest.java | 9 +++++---- .../SportsSportsActivityFeedClientImplTest.java | 5 +++-- .../feed/model/ActivityTestUtils.java | 17 ----------------- .../feed/model/SportsActivityTestUtils.java | 17 +++++++++++++++++ ...dSportsSportsActivityFeedServerImplTest.java | 10 +++++----- 7 files changed, 39 insertions(+), 35 deletions(-) delete mode 100644 sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/ActivityTestUtils.java create mode 100644 sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/SportsActivityTestUtils.java diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java index 6c889ce..05fe9c7 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java @@ -1,12 +1,13 @@ package com.diffusiondata.gateway.example.sportsactivity.feed; -import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils.createPopulatedSportsActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.StringContains.containsString; import org.junit.jupiter.api.Test; import com.diffusiondata.gateway.example.common.jackson.ObjectMapperUtils; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils; import com.fasterxml.jackson.databind.ObjectMapper; class SportsActivityJsonTest { @@ -18,7 +19,7 @@ void testWriteValueAsString() throws Exception { final String result = - objectMapper.writeValueAsString(createPopulatedActivity()); + objectMapper.writeValueAsString(SportsActivityTestUtils.createPopulatedSportsActivity()); assertThat(result, containsString("dateOfActivity")); } diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java index 8b07964..f68a78f 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java @@ -1,7 +1,7 @@ package com.diffusiondata.gateway.example.sportsactivity.feed; import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; -import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils.createPopulatedSportsActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsEqual.equalTo; import static org.hamcrest.core.IsNull.notNullValue; @@ -33,6 +33,7 @@ import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -90,7 +91,7 @@ void afterEachTest() { void testOnMessageWhenServiceStateIsActive() throws Exception { - final SportsActivity sportsActivity = createPopulatedActivity(); + final SportsActivity sportsActivity = SportsActivityTestUtils.createPopulatedSportsActivity(); final String expectedTopicPath = DEFAULT_STREAMING_TOPIC_PREFIX + "/" + sportsActivity.getSport(); @@ -112,7 +113,7 @@ void testOnMessageWhenServiceStateIsActive() void testOnMessageWhenServiceStateIsActiveAndPublishExceptionIsThrown() throws Exception { - final SportsActivity sportsActivity = createPopulatedActivity(); + final SportsActivity sportsActivity = SportsActivityTestUtils.createPopulatedSportsActivity(); final String topicPath = DEFAULT_STREAMING_TOPIC_PREFIX + "/" + sportsActivity.getSport(); @@ -135,7 +136,7 @@ void testOnMessageWhenServiceStateIsActiveAndPublishExceptionIsThrown() void testOnMessageWhenServiceStateIsActiveAndCheckedExceptionIsThrown() throws Exception { - final SportsActivity sportsActivity = createPopulatedActivity(); + final SportsActivity sportsActivity = SportsActivityTestUtils.createPopulatedSportsActivity(); when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); @@ -151,7 +152,7 @@ void testOnMessageWhenServiceStateIsNotActive() { when(stateHandlerMock.getState()) .thenReturn(ServiceState.PAUSED); - ((SportsActivityFeedListener) handler).onMessage(createPopulatedActivity()); + ((SportsActivityFeedListener) handler).onMessage(SportsActivityTestUtils.createPopulatedSportsActivity()); } @Test diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java index f9fc9ac..230a885 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java @@ -1,7 +1,7 @@ package com.diffusiondata.gateway.example.sportsactivity.feed; import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedSnapshotPollingSourceHandlerImpl.DEFAULT_POLLING_TOPIC_PATH; -import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils.createPopulatedSportsActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsEqual.equalTo; import static org.hamcrest.core.IsInstanceOf.instanceOf; @@ -36,6 +36,7 @@ import com.diffusiondata.gateway.util.Util; import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -110,7 +111,7 @@ void testPollWhenServiceStateIsActiveAndLatestActivitiesHasItems() throws Exception { final Collection activities = - List.of(createPopulatedActivity()); + List.of(SportsActivityTestUtils.createPopulatedSportsActivity()); final String jsonAsString = "{}"; @@ -137,7 +138,7 @@ void testPollWhenServiceStateIsActiveAndPublishExceptionIsThrown() throws Exception { final Collection activities = - List.of(createPopulatedActivity()); + List.of(SportsActivityTestUtils.createPopulatedSportsActivity()); final String jsonAsString = "{}"; @@ -167,7 +168,7 @@ void testPollWhenServiceStateIsActiveAndCheckedExceptionIsThrown() throws Exception { final Collection activities = - List.of(createPopulatedActivity()); + List.of(SportsActivityTestUtils.createPopulatedSportsActivity()); when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java index 07e9852..1d6003d 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java @@ -1,6 +1,6 @@ package com.diffusiondata.pretend.example.sportsactivity.feed.client.impl; -import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils.createPopulatedSportsActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsEqual.equalTo; import static org.mockito.Mockito.mock; @@ -20,6 +20,7 @@ import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; +import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils; import com.diffusiondata.pretend.example.sportsactivity.feed.service.SportsActivityFeedServer; @ExtendWith(MockitoExtension.class) @@ -68,7 +69,7 @@ void testUnregisterListener() { @Test void testGetActivityFeed() { final Collection expectedActivities = - List.of(createPopulatedActivity()); + List.of(SportsActivityTestUtils.createPopulatedSportsActivity()); when(sportsActivityFeedServerMock.getLatestSportsActivities()) .thenReturn(expectedActivities); diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/ActivityTestUtils.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/ActivityTestUtils.java deleted file mode 100644 index a82b33f..0000000 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/ActivityTestUtils.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.diffusiondata.pretend.example.sportsactivity.feed.model; - -import java.time.Instant; - -public final class ActivityTestUtils { - private ActivityTestUtils() { - // Private constructor to prevent creation - } - - public static SportsActivity createPopulatedActivity() { - return createPopulatedActivity("some-sport"); - } - - public static SportsActivity createPopulatedActivity(String sport) { - return new SportsActivity(sport, "c", "w", Instant.now()); - } -} diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/SportsActivityTestUtils.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/SportsActivityTestUtils.java new file mode 100644 index 0000000..1064a58 --- /dev/null +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/model/SportsActivityTestUtils.java @@ -0,0 +1,17 @@ +package com.diffusiondata.pretend.example.sportsactivity.feed.model; + +import java.time.Instant; + +public final class SportsActivityTestUtils { + private SportsActivityTestUtils() { + // Private constructor to prevent creation + } + + public static SportsActivity createPopulatedSportsActivity() { + return createPopulatedSportsActivity("some-sport"); + } + + public static SportsActivity createPopulatedSportsActivity(String sport) { + return new SportsActivity(sport, "c", "w", Instant.now()); + } +} diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java index 715e32f..8c5a767 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java @@ -1,6 +1,6 @@ package com.diffusiondata.pretend.example.sportsactivity.feed.service.impl; -import static com.diffusiondata.pretend.example.sportsactivity.feed.model.ActivityTestUtils.createPopulatedActivity; +import static com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils.createPopulatedSportsActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.collection.IsEmptyIterable.emptyIterable; import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize; @@ -127,7 +127,7 @@ void testGetLatestSportsActivitiesWhenNoneGenerated() { @Test @Order(30) void testInternalUpdateStateAndListenersWhenListenerRegistered() { - final SportsActivity sportsActivity = createPopulatedActivity(SPORT); + final SportsActivity sportsActivity = createPopulatedSportsActivity(SPORT); doNothing().when(sportsActivityFeedListenerMock) .onMessage(sportsActivity); @@ -143,7 +143,7 @@ void testInternalUpdateStateAndListenersWhenListenerRegistered() { @Test @Order(33) void testInternalUpdateStateAndListenersWhenNoListenersRegistered() { - final SportsActivity sportsActivity = createPopulatedActivity(SPORT); + final SportsActivity sportsActivity = createPopulatedSportsActivity(SPORT); getImpl().internalUpdateStateAndListeners(sportsActivity); @@ -153,7 +153,7 @@ void testInternalUpdateStateAndListenersWhenNoListenersRegistered() { @Test @Order(36) void testInternalUpdateStateAndListenersWhenListenerRegisterAndExceptions() { - final SportsActivity sportsActivity = createPopulatedActivity(SPORT); + final SportsActivity sportsActivity = createPopulatedSportsActivity(SPORT); doThrow(IllegalStateException.class) .when(sportsActivityFeedListenerMock) @@ -172,7 +172,7 @@ void testInternalUpdateStateAndListenersWhenListenerRegisterAndExceptions() { void testGetLatestSportsActivitiesWhenSomeGenerated() throws Exception { - final SportsActivity sportsActivity = createPopulatedActivity(SPORT); + final SportsActivity sportsActivity = createPopulatedSportsActivity(SPORT); when(randomSportsActivitySupplierMock.get()) .thenReturn(sportsActivity); From ba7d4ad4db2dd2c568c6511f8e04cc92ea256110 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sun, 24 Nov 2024 13:43:29 +0000 Subject: [PATCH 18/33] DOC-428: sports activity feed streaming and polling example. --- ...eedListenerStreamingSourceHandlerImpl.java | 19 +++++-- ...tsActivityFeedGatewayApplicationTest.java} | 2 +- ...stenerStreamingSourceHandlerImplTest.java} | 49 +++++++++++++------ ...SnapshotPollingSourceHandlerImplTest.java} | 2 +- .../feed/SportsActivityJsonTest.java | 3 +- ... => SportsActivityFeedClientImplTest.java} | 2 +- ...tendSportsActivityFeedServerImplTest.java} | 11 +++-- 7 files changed, 61 insertions(+), 27 deletions(-) rename sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/{SportsSportsActivityFeedGatewayApplicationTest.java => SportsActivityFeedGatewayApplicationTest.java} (99%) rename sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/{SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java => SportsActivityFeedListenerStreamingSourceHandlerImplTest.java} (80%) rename sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/{SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java => SportsActivityFeedSnapshotPollingSourceHandlerImplTest.java} (99%) rename sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/{SportsSportsActivityFeedClientImplTest.java => SportsActivityFeedClientImplTest.java} (98%) rename sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/{PretendSportsSportsActivityFeedServerImplTest.java => PretendSportsActivityFeedServerImplTest.java} (95%) diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java index 2d96d2a..e9873bd 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import net.jcip.annotations.GuardedBy; import net.jcip.annotations.ThreadSafe; @ThreadSafe @@ -39,6 +40,7 @@ public final class SportsActivityFeedListenerStreamingSourceHandlerImpl private final ObjectMapper objectMapper; private final String topicPrefix; + @GuardedBy("this") private String listenerIdentifier; public SportsActivityFeedListenerStreamingSourceHandlerImpl( @@ -91,7 +93,7 @@ public void onMessage(SportsActivity sportsActivity) { } @Override - public CompletableFuture start() { + public synchronized CompletableFuture start() { listenerIdentifier = sportsActivityFeedClient.registerListener(this); @@ -101,8 +103,9 @@ public CompletableFuture start() { } @Override - public CompletableFuture stop() { + public synchronized CompletableFuture stop() { sportsActivityFeedClient.unregisterListener(listenerIdentifier); + listenerIdentifier = null; LOG.info("Stopped sports activity feed streaming handler"); @@ -110,8 +113,9 @@ public CompletableFuture stop() { } @Override - public CompletableFuture pause(PauseReason reason) { + public synchronized CompletableFuture pause(PauseReason reason) { sportsActivityFeedClient.unregisterListener(listenerIdentifier); + listenerIdentifier = null; LOG.info("Paused sports activity feed streaming handler"); @@ -119,7 +123,7 @@ public CompletableFuture pause(PauseReason reason) { } @Override - public CompletableFuture resume(ResumeReason reason) { + public synchronized CompletableFuture resume(ResumeReason reason) { listenerIdentifier = sportsActivityFeedClient.registerListener(this); @@ -134,4 +138,11 @@ public CompletableFuture resume(ResumeReason reason) { String getTopicPrefix() { return topicPrefix; } + + /** + * package for tests. + */ + public String getListenerIdentifier() { + return listenerIdentifier; + } } diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplicationTest.java similarity index 99% rename from sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplicationTest.java index b510e47..9f3a510 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedGatewayApplicationTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplicationTest.java @@ -38,7 +38,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) -class SportsSportsActivityFeedGatewayApplicationTest { +class SportsActivityFeedGatewayApplicationTest { @Mock private SportsActivityFeedClient sportsActivityFeedClientMock; diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImplTest.java similarity index 80% rename from sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImplTest.java index f68a78f..b34b874 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImplTest.java @@ -33,12 +33,11 @@ import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedClient; import com.diffusiondata.pretend.example.sportsactivity.feed.client.SportsActivityFeedListener; import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivity; -import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) -class SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest { +class SportsActivityFeedListenerStreamingSourceHandlerImplTest { @Mock private SportsActivityFeedClient sportsActivityFeedClientMock; @@ -59,7 +58,8 @@ class SportsSportsActivityFeedListenerStreamingSourceHandlerImplTest { @BeforeEach void beforeEachTest() { when(serviceDefinitionMock.getParameters()) - .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); + .thenReturn(Map.of("topicPrefix", + DEFAULT_STREAMING_TOPIC_PREFIX)); handler = new SportsActivityFeedListenerStreamingSourceHandlerImpl( sportsActivityFeedClientMock, @@ -68,9 +68,7 @@ void beforeEachTest() { stateHandlerMock, objectMapperMock); - final String topicPrefix = - ((SportsActivityFeedListenerStreamingSourceHandlerImpl) handler) - .getTopicPrefix(); + final String topicPrefix = getImplsTopicPrefix(); assertThat(topicPrefix, notNullValue()); assertThat(topicPrefix, equalTo(DEFAULT_STREAMING_TOPIC_PREFIX)); @@ -91,7 +89,7 @@ void afterEachTest() { void testOnMessageWhenServiceStateIsActive() throws Exception { - final SportsActivity sportsActivity = SportsActivityTestUtils.createPopulatedSportsActivity(); + final SportsActivity sportsActivity = createPopulatedSportsActivity(); final String expectedTopicPath = DEFAULT_STREAMING_TOPIC_PREFIX + "/" + sportsActivity.getSport(); @@ -113,7 +111,7 @@ void testOnMessageWhenServiceStateIsActive() void testOnMessageWhenServiceStateIsActiveAndPublishExceptionIsThrown() throws Exception { - final SportsActivity sportsActivity = SportsActivityTestUtils.createPopulatedSportsActivity(); + final SportsActivity sportsActivity = createPopulatedSportsActivity(); final String topicPath = DEFAULT_STREAMING_TOPIC_PREFIX + "/" + sportsActivity.getSport(); @@ -136,7 +134,7 @@ void testOnMessageWhenServiceStateIsActiveAndPublishExceptionIsThrown() void testOnMessageWhenServiceStateIsActiveAndCheckedExceptionIsThrown() throws Exception { - final SportsActivity sportsActivity = SportsActivityTestUtils.createPopulatedSportsActivity(); + final SportsActivity sportsActivity = createPopulatedSportsActivity(); when(stateHandlerMock.getState()) .thenReturn(ServiceState.ACTIVE); @@ -152,7 +150,8 @@ void testOnMessageWhenServiceStateIsNotActive() { when(stateHandlerMock.getState()) .thenReturn(ServiceState.PAUSED); - ((SportsActivityFeedListener) handler).onMessage(SportsActivityTestUtils.createPopulatedSportsActivity()); + ((SportsActivityFeedListener) handler) + .onMessage(createPopulatedSportsActivity()); } @Test @@ -166,13 +165,16 @@ void testStart() { void testStop() { final String listenerIdentifier = invokeStart(); - when(sportsActivityFeedClientMock.unregisterListener(listenerIdentifier)) + when(sportsActivityFeedClientMock + .unregisterListener(listenerIdentifier)) .thenReturn(true); final CompletableFuture cf = handler.stop(); assertThat(cf, notNullValue()); assertThat(cf.join(), nullValue()); + + assertThat(getImplsListenerIdentifier(), nullValue()); } @Test @@ -180,13 +182,16 @@ void testStop() { void testPause() { final String listenerIdentifier = invokeStart(); - when(sportsActivityFeedClientMock.unregisterListener(listenerIdentifier)) + when(sportsActivityFeedClientMock + .unregisterListener(listenerIdentifier)) .thenReturn(true); final CompletableFuture cf = handler.pause(PauseReason.REQUESTED); assertThat(cf, notNullValue()); assertThat(cf.join(), nullValue()); + + assertThat(getImplsListenerIdentifier(), nullValue()); } @Test @@ -194,7 +199,8 @@ void testPause() { void testResume() { final String listenerIdentifier = "listener-identifier"; - final SportsActivityFeedListener listener = (SportsActivityFeedListener) handler; + final SportsActivityFeedListener listener = + (SportsActivityFeedListener) handler; when(sportsActivityFeedClientMock.registerListener(listener)) .thenReturn(listenerIdentifier); @@ -203,12 +209,15 @@ void testResume() { assertThat(cf, notNullValue()); assertThat(cf.join(), nullValue()); + + assertThat(getImplsListenerIdentifier(), notNullValue()); } private String invokeStart() { final String listenerIdentifier = "listener-identifier"; - final SportsActivityFeedListener listener = (SportsActivityFeedListener) handler; + final SportsActivityFeedListener listener = + (SportsActivityFeedListener) handler; when(sportsActivityFeedClientMock.registerListener(listener)) .thenReturn(listenerIdentifier); @@ -218,6 +227,18 @@ private String invokeStart() { assertThat(cf, notNullValue()); assertThat(cf.join(), nullValue()); + assertThat(getImplsListenerIdentifier(), notNullValue()); + return listenerIdentifier; } + + private String getImplsTopicPrefix() { + return ((SportsActivityFeedListenerStreamingSourceHandlerImpl) handler) + .getTopicPrefix(); + } + + private String getImplsListenerIdentifier() { + return ((SportsActivityFeedListenerStreamingSourceHandlerImpl) handler) + .getListenerIdentifier(); + } } diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImplTest.java similarity index 99% rename from sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImplTest.java index 230a885..6cc3c3d 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImplTest.java @@ -41,7 +41,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) -class SportsSportsActivityFeedSnapshotPollingSourceHandlerImplTest { +class SportsActivityFeedSnapshotPollingSourceHandlerImplTest { @Mock private SportsActivityFeedClient sportsActivityFeedClientMock; diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java index 05fe9c7..704f725 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityJsonTest.java @@ -7,7 +7,6 @@ import org.junit.jupiter.api.Test; import com.diffusiondata.gateway.example.common.jackson.ObjectMapperUtils; -import com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils; import com.fasterxml.jackson.databind.ObjectMapper; class SportsActivityJsonTest { @@ -19,7 +18,7 @@ void testWriteValueAsString() throws Exception { final String result = - objectMapper.writeValueAsString(SportsActivityTestUtils.createPopulatedSportsActivity()); + objectMapper.writeValueAsString(createPopulatedSportsActivity()); assertThat(result, containsString("dateOfActivity")); } diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImplTest.java similarity index 98% rename from sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImplTest.java index 1d6003d..f36b472 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsSportsActivityFeedClientImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/client/impl/SportsActivityFeedClientImplTest.java @@ -24,7 +24,7 @@ import com.diffusiondata.pretend.example.sportsactivity.feed.service.SportsActivityFeedServer; @ExtendWith(MockitoExtension.class) -class SportsSportsActivityFeedClientImplTest { +class SportsActivityFeedClientImplTest { @Mock private SportsActivityFeedServer sportsActivityFeedServerMock; diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImplTest.java similarity index 95% rename from sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImplTest.java index 8c5a767..edcd9b5 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsSportsActivityFeedServerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/pretend/example/sportsactivity/feed/service/impl/PretendSportsActivityFeedServerImplTest.java @@ -33,7 +33,7 @@ import com.diffusiondata.pretend.example.sportsactivity.feed.service.SportsActivityFeedServer; @ExtendWith(MockitoExtension.class) -class PretendSportsSportsActivityFeedServerImplTest { +class PretendSportsActivityFeedServerImplTest { private static final String SPORT = "tennis"; @Mock @@ -143,7 +143,8 @@ void testInternalUpdateStateAndListenersWhenListenerRegistered() { @Test @Order(33) void testInternalUpdateStateAndListenersWhenNoListenersRegistered() { - final SportsActivity sportsActivity = createPopulatedSportsActivity(SPORT); + final SportsActivity sportsActivity = + createPopulatedSportsActivity(SPORT); getImpl().internalUpdateStateAndListeners(sportsActivity); @@ -153,7 +154,8 @@ void testInternalUpdateStateAndListenersWhenNoListenersRegistered() { @Test @Order(36) void testInternalUpdateStateAndListenersWhenListenerRegisterAndExceptions() { - final SportsActivity sportsActivity = createPopulatedSportsActivity(SPORT); + final SportsActivity sportsActivity = + createPopulatedSportsActivity(SPORT); doThrow(IllegalStateException.class) .when(sportsActivityFeedListenerMock) @@ -172,7 +174,8 @@ void testInternalUpdateStateAndListenersWhenListenerRegisterAndExceptions() { void testGetLatestSportsActivitiesWhenSomeGenerated() throws Exception { - final SportsActivity sportsActivity = createPopulatedSportsActivity(SPORT); + final SportsActivity sportsActivity = + createPopulatedSportsActivity(SPORT); when(randomSportsActivitySupplierMock.get()) .thenReturn(sportsActivity); From 1aeed5c38bdf1f7f12cc115dfb06f60a8a24a7fb Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sun, 24 Nov 2024 14:02:30 +0000 Subject: [PATCH 19/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 246 +++++++++++++----- ...rts-activity-feed-in-diffusion-console.png | Bin 0 -> 107162 bytes .../SportsActivityFeedGatewayApplication.java | 11 +- ...eedListenerStreamingSourceHandlerImpl.java | 2 +- ...yFeedSnapshotPollingSourceHandlerImpl.java | 2 +- .../src/main/resources/configuration.json | 19 +- 6 files changed, 186 insertions(+), 94 deletions(-) create mode 100644 sports-activity-feed-adapter/polling-sports-activity-feed-in-diffusion-console.png diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index 71b7d0f..6792ba5 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -16,7 +16,7 @@ of Diffusion. --- -# Activity feed Gateway adapter example +# Sports activity feed Gateway adapter example ## Introduction In this tutorial, you will learn how to use the 'Diffusion Gateway Framework' to develop a Gateway adapter for feeding streaming and batch/polled data into your Diffusion server. The Gateway framework makes it easy to integrate with different datasources for getting data in and out of Diffusion. You will see how the Gateway Framework provides a common and consistent application structure and a higher level of abstraction over the Diffusion SDK, as well as handling things like retries and timeouts. @@ -24,26 +24,26 @@ In this tutorial, you will learn how to use the 'Diffusion Gateway Framework' to After completing the tutorial, you can expect to understand how the Gateway framework helps to quickly develop adapters for getting data in and out of Diffusion. You can review and understand the solution code, learn how to run the example, and see the data updated in the Diffusion Console. ## Overview -This example uses the concept of a sporting sportsActivity feed (think along the lines of popular exercise/social networks). Naturally, we don't build the platform for this tutorial; instead, we use a pretend sportsActivity feed server that generates realistic random sports sportsActivity data. +This example uses the concept of a sporting activity feed (think along the lines of popular exercise/social networks). Naturally, we don't build the platform for this tutorial; instead, we use a pretend sports activity feed server that generates realistic random sports activity data. -The pretend sportsActivity feed server provides a client API that allows an application to subscribe to a feed of activities, with the changes pushed to the subscribed clients as they happen - so, this would be like someone completing an sportsActivity, uploading it and then the sportsActivity is sent as an event to subscribers. Additionally, the pretend sportsActivity feed client API has a mechanism for requesting a snapshot of the latest activities at a point in time. +The pretend sports activity feed server provides a client API that allows an application to subscribe to a feed of activities, with the changes pushed to the subscribed clients as they happen - so, this would be like someone completing an sports activity, uploading it and then the sports activity is sent as an event to subscribers. Additionally, the pretend sports activity feed client API has a mechanism for requesting a snapshot of the latest activities at a point in time. -In this tutorial, we'll integrate the Gateway Framework with the pretend sportsActivity feed server and demonstrate data streaming into the Gateway adapter and polling to receive the sportsActivity snapshot. +In this tutorial, we'll integrate the Gateway Framework with the pretend sports activity feed server and demonstrate data streaming into the Gateway adapter and polling to receive the sports activity snapshot. The final solution comprises the following: -- Pretend sports sportsActivity feed server - this provides a client API for receiving streaming events and the ability to request a snapshot of the latest activities. -- Gateway adapter - this is the application you will learn to build; it will integrate with the pretend sports sportsActivity feed server and put the data into your Diffusion server. +- Pretend sports activity feed server - this provides a client API for receiving streaming events and the ability to request a snapshot of the latest activities. +- Gateway adapter - this is the application you will learn to build; it will integrate with the pretend sports activity feed server and put the data into your Diffusion server. - Diffusion server - this is a running instance of Diffusion; you can run Diffusion in several different ways, such as running locally on your machine or connecting to a remote Diffusion server. -The sportsActivity domain object has the following attributes: -- **Sport:** the sporting sportsActivity, such as swimming, sailing, tennis and other sports. -- **Country:** the country where the sports sportsActivity took place. -- **Winner:** the name of the person who won the sporting sportsActivity. -- **Date of sportsActivity:** when the sporting sportsActivity took place. +The sports activity domain object has the following attributes: +- **Sport:** the sporting activity, such as swimming, sailing, tennis and other sports. +- **Country:** the country where the sports activity took place. +- **Winner:** the name of the person who won the sporting activity. +- **Date of activity:** when the sporting activity took place. -The pretend sportsActivity feed client has the following features: -- **Register a listener:** an sportsActivity feed listener instance is required, with a callback method of 'onMessage' called when a new sportsActivity is sent to the subscriber (in our case, the Gateway adapter). -- **Unregister a listener:** a way of unregistering from the sportsActivity feed to stop receiving updates. +The pretend sports activity feed client has the following features: +- **Register a listener:** a sports activity feed listener instance is required, with a callback method of 'onMessage' called when a new sports activity is sent to the subscriber (in our case, the Gateway adapter). +- **Unregister a listener:** a way of unregistering from the sports activity feed to stop receiving updates. - **Get latest activities:** returns a snapshot list of the latest sporting activities when called. Diagram of final solution: @@ -52,18 +52,18 @@ Diagram of final solution: flowchart LR %% Nodes -AFS("Pretend
Activity feed
server"):::orange +AFS("Pretend
Sports activity feed
server"):::orange AFG("Activity feed
Gateway adapter"):::green DIF("Diffusion
server"):::blue %% Edges -AFS -. 1a) Send sportsActivity event .-> AFG +AFS -. 1a) Send sports activity event .-> AFG AFG -- 2a) Invoke get latest activities ---> AFS AFS -- 2b) Return latest activities --> AFG -AFG -- 1b) Update specific \n sport sportsActivity --> DIF +AFG -- 1b) Update specific \n sports activity --> DIF AFG -- 2c) Update the \n activities snapshot ---> DIF %% Styling @@ -73,7 +73,7 @@ classDef blue fill:#BBDEFB,stroke:#1976D2,stroke-width:2px; ``` ## Prerequisites -To get started with the sports sportsActivity feed example, you will need the following: +To get started with the sports activity feed example, you will need the following: - Java 11. - Your preferred Java IDE. - A running Diffusion server, this can be running locally or remotely; some of the options are: @@ -82,13 +82,13 @@ To get started with the sports sportsActivity feed example, you will need the fo - Use Diffusion Cloud, the DiffusionData SaaS offering. - Connect to a Diffusion server running remotely. -The sportsActivity feed example code is available on GitHub and is part of the overall Gateway examples project: +The sports activity feed example code is available on GitHub and is part of the overall Gateway examples project: * [diffusiondata/gateway-examples](https://github.com/diffusiondata/gateway-examples) -Follow the README file within the sportsActivity-feed-adapter module to start building the project and running the example. +Follow the README file within the sports-activity-feed-adapter module to start building the project and running the example. ## Instructions -Developing the sportsActivity feed Gateway adapter requires very little code and just some configuration. Here's what we are going to create: +Developing the sports activity feed Gateway adapter requires very little code and just some configuration. Here's what we are going to create: - A class that implements the `GatewayApplication` interface. - A class that implements the `PollingSourceHandler` interface. - A class that implements the `StreamingSourceHandler` interface. @@ -98,7 +98,7 @@ Developing the sportsActivity feed Gateway adapter requires very little code and Note: the code is available in GitHub, so you may find referring to the completed solution helpful. ### Gateway application class -Firstly, create a class called `ActivityFeedGatewayApplication` that implements the `GatewayApplication` interface. The class is a standard way of writing Gateway adapters. An adapter can then have different types of `ServiceHandler` for handling streaming, polling or sinking data against your chosen datasources. You will need to implement a few methods, such as: +Firstly, create a class called `SportsActivityFeedGatewayApplication` that implements the `GatewayApplication` interface. The class is a standard way of writing Gateway adapters. An adapter can then have different types of `ServiceHandler` for handling streaming, polling or sinking data against your chosen datasources. You will need to implement a few methods, such as: - `getApplicationDetails` - provides details of the adapter, such as which types of `ServiceHandler` are available and can be configured. - `stop` - called when the Gateway adapter is shutdown. @@ -106,30 +106,45 @@ As we go through the tutorial, you will need to override two methods: - `addPollingSource` - adds a polling source to the adapter. - `addStreamingSource` - adds a streaming source to the adapter. -Because our Gateway adapter will integrate with the pretend sportsActivity feed server, we'll pass an `ActivityFeedClient` reference in the constructor for later use by the streaming and polling service handlers. The `ObjectMapper` is used to convert our Activity object into JSON. Our code will initially look something like: +Because our Gateway adapter will integrate with the pretend sports activity feed server, we'll pass an `SportsActivityFeedClient` reference in the constructor for later use by the streaming and polling service handlers. The `ObjectMapper` is used to convert our SportsActivity object into JSON. Our code will initially look something like: ```java -public class ActivityFeedGatewayApplication +public final class SportsActivityFeedGatewayApplication implements GatewayApplication { - - private final ObjectMapper objectMapper; - private final ActivityFeedClient sportsActivityFeedClient; - - public ActivityFeedGatewayApplication( - ActivityFeedClient sportsActivityFeedClient, + + static final String APPLICATION_TYPE = + "sports-activity-feed-application"; + + static final String STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME = + "streaming-sports-activity-feed-service"; + + static final String POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME = + "polling-sports-activity-feed-service"; + + private static final Logger LOG = + LoggerFactory.getLogger(SportsActivityFeedGatewayApplication.class); + + private final SportsActivityFeedClient sportsActivityFeedClient; + private final ObjectMapper objectMapper; + + public SportsActivityFeedGatewayApplication( + SportsActivityFeedClient sportsActivityFeedClient, ObjectMapper objectMapper) { - - this.sportsActivityFeedClient = sportsActivityFeedClient; - this.objectMapper = objectMapper; + + this.sportsActivityFeedClient = + requireNonNull(sportsActivityFeedClient, + "sportsActivityFeedClient"); + + this.objectMapper = + requireNonNull(objectMapper, "objectMapper"); } @Override public ApplicationDetails getApplicationDetails() throws ApplicationConfigurationException { - - // You will add service handlers as you progress through the tutorial + return DiffusionGatewayFramework.newApplicationDetailsBuilder() - .build(APPLICATION_TYPE, 1); + .build(APPLICATION_TYPE, 1); } @Override @@ -137,61 +152,66 @@ public class ActivityFeedGatewayApplication LOG.info("Application stop"); return CompletableFuture.completedFuture(null); - } -``` + } +}``` ### Gateway application runner class Create a new class called `Runner` - a simple Java class with a main method; this is a typical idiom Gateway adapters use for launching the Gateway application. ```java -public class Runner { - public static void main(String[] args) { - final ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - - DiffusionGatewayFramework.start( - new ActivityFeedGatewayApplication( - ActivityFeedClientImpl.connectToActivityFeedServer(), - objectMapper); +public final class Runner { + public static void main(String[] args) { + DiffusionGatewayFramework.start(createGatewayApplication()); + } + + static GatewayApplication createGatewayApplication() { + return new SportsActivityFeedGatewayApplication( + SportsActivityFeedClientImpl.connectToActivityFeedServer(), + createAndConfigureObjectMapper()); // Utility method for mapper date formatting } } ``` ### Polling source handler class and configuration -Create a class called `ActivityFeedSnapshotPollingSourceHandlerImpl` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend sportsActivity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: +Create a class called `SportsActivityFeedSnapshotPollingSourceHandlerImpl` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend sports activity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: - `poll` - this method is periodically called by the Gateway framework based on configuration. - `pause` - called when the Gateway adapter enters the paused state. - `resume` - is called when the Gateway adapter can resume. -In your `poll` method, we will call the pretend sportsActivity feed server's `getLatestActivities()` using the `ActivityFeedClient` reference passed into the constructor. Below is the complete code for the polling source handler: +In your `poll` method, we will call the pretend sports activity feed server's `getSportsLatestActivities()` using the `SportsActivityFeedClient` reference passed into the constructor. Below is the complete code for the polling source handler: ```java -public class ActivityFeedSnapshotPollingSourceHandlerImpl +public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl implements PollingSourceHandler { static final String DEFAULT_POLLING_TOPIC_PATH = - "polling/sportsActivity/feed"; + "default/sports/activity/feed/snapshot"; private static final Logger LOG = - LoggerFactory.getLogger(ActivityFeedSnapshotPollingSourceHandlerImpl.class); + LoggerFactory.getLogger( + SportsActivityFeedSnapshotPollingSourceHandlerImpl.class); - private final ActivityFeedClient sportsActivityFeedClient; + private final SportsActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; private final StateHandler stateHandler; private final ObjectMapper objectMapper; private final String topicPath; - public ActivityFeedSnapshotPollingSourceHandlerImpl( - ActivityFeedClient sportsActivityFeedClient, + public SportsActivityFeedSnapshotPollingSourceHandlerImpl( + SportsActivityFeedClient sportsActivityFeedClient, ServiceDefinition serviceDefinition, Publisher publisher, StateHandler stateHandler, ObjectMapper objectMapper) { - this.sportsActivityFeedClient = sportsActivityFeedClient; - this.publisher = publisher; - this.stateHandler = stateHandler; - this.objectMapper = objectMapper; + this.sportsActivityFeedClient = + requireNonNull(sportsActivityFeedClient, + "sportActivityFeedClient"); + + this.publisher = requireNonNull(publisher, "publisher"); + this.stateHandler = requireNonNull(stateHandler, "stateHandler"); + requireNonNull(serviceDefinition, "serviceDefinition"); + this.objectMapper = requireNonNull(objectMapper, "objectMapper"); topicPath = serviceDefinition.getParameters() .getOrDefault("topicPath", DEFAULT_POLLING_TOPIC_PATH) @@ -208,8 +228,8 @@ public class ActivityFeedSnapshotPollingSourceHandlerImpl return pollCf; } - final Collection activities = - sportsActivityFeedClient.getLatestActivities(); + final Collection activities = + sportsActivityFeedClient.getLatestSportsActivities(); if (activities.isEmpty()) { pollCf.complete(null); @@ -233,10 +253,8 @@ public class ActivityFeedSnapshotPollingSourceHandlerImpl catch (JsonProcessingException | PayloadConversionException e) { - LOG.error("Cannot publish", e); + LOG.error("Failed to convert sports activity to JSON", e); pollCf.completeExceptionally(e); - - return pollCf; } return pollCf; @@ -244,20 +262,112 @@ public class ActivityFeedSnapshotPollingSourceHandlerImpl @Override public CompletableFuture pause(PauseReason reason) { - LOG.info("Paused sportsActivity feed polling handler"); + LOG.info("Paused sports activity feed polling handler"); return CompletableFuture.completedFuture(null); } @Override public CompletableFuture resume(ResumeReason reason) { - LOG.info("Resumed sportsActivity feed polling handler"); + LOG.info("Resumed sports activity feed polling handler"); return CompletableFuture.completedFuture(null); } } ``` -### Streaming source handler class and configuration -For our example, this will handle the activities sent from the pretend sportsActivity feed server and put the data into Diffusion topics. +#### Add polling service to the Gateway application class +Now you have created the polling service class, we can add it to the `getApplicationDetails` as a supported service type and then include the code within the `addPollingSource` method that will instantiate an instance of the polling source handler class. + +```java +@Override +public ApplicationDetails getApplicationDetails() + throws ApplicationConfigurationException { + + return DiffusionGatewayFramework.newApplicationDetailsBuilder() + .addServiceType( + POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME, + ServiceMode.POLLING_SOURCE, + "Polled sports activity feed snapshot", + null) + .build(APPLICATION_TYPE, 1); +} + +@Override +public PollingSourceHandler addPollingSource( + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler) + throws InvalidConfigurationException { + + final String serviceType = + serviceDefinition.getServiceType().getName(); + + if (POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { + return new SportsActivityFeedSnapshotPollingSourceHandlerImpl( + sportsActivityFeedClient, + serviceDefinition, + publisher, + stateHandler, + objectMapper); + } + + throw new InvalidConfigurationException( + "Unknown service type: " + serviceType); +} +``` + +#### Create the configuration file with the polling service instance +The code for the polling source handler is now complete, so you need to configure the Gateway adapter to use the new polling service. Gateway adapters use a standard JSON configuration file; schema definitions exist for validation and IDE support. Below is the configuration with an instance of the polling handler configured: + +```json +{ + "id": "sports-activity-feed-adapter-1", + "framework-version": 1, + "application-version": 1, + "diffusion": { + "url": "ws://localhost:8080", + "principal": "admin", + "password": "password", + "reconnectIntervalMs": 5000 + }, + "services": [ + { + "serviceName": "polling-sports-activity-feed-service-1", + "serviceType": "polling-sports-activity-feed-service", + "config": { + "framework": { + "pollIntervalMs": 4500, + "pollTimeoutMs": 300000, + "topicProperties": { + "topicType": "JSON", + "persistencePolicy": "SESSION", + "publishValuesOnly": false, + "dontRetainValue": false + } + }, + "application": { + "topicPath": "sports/activity/feed/snapshot" + } + } + } + ] +} +``` + +Note: change the Diffusion URL, principal and password to match what is required to connect to your Diffusion server. + +Note: the service type "polling-sports-activity-feed-service" is how the configuration is linked to the code in the `getApplicationDetails` method. + +#### Run the adapter with the polling service added +After building the project, you can run the sports activity feed Gateway adapter from the root of Gateway examples project: + +```shell +java -Dgateway.config.file=sports-activity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\sports-activity-feed-adapter\target\sports-activity-feed-adapter-1.0.0-jar-with-dependencies.jar +``` + +Note: the system property `-Dgateway.config.use-local-services=true` tells the adapter to use the configuration that is specified in the configuration file and not to use any configuration cached in the Diffusion server. + +Once the Gateway adapter has started up, new topics should appear in Diffusion. Looking in the Diffusion console, it will look something like below: +![[polling-sports-activity-feed-in-diffusion-console.png]] diff --git a/sports-activity-feed-adapter/polling-sports-activity-feed-in-diffusion-console.png b/sports-activity-feed-adapter/polling-sports-activity-feed-in-diffusion-console.png new file mode 100644 index 0000000000000000000000000000000000000000..8a3242dde88cc4911203e9827d973b60cbc2ba91 GIT binary patch literal 107162 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 literal 0 HcmV?d00001 diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java index 99528de..cda9c3d 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java @@ -58,17 +58,16 @@ public ApplicationDetails getApplicationDetails() throws ApplicationConfigurationException { return DiffusionGatewayFramework.newApplicationDetailsBuilder() + .addServiceType( + POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME, + ServiceMode.POLLING_SOURCE, + "Polled sports activity feed snapshot", + null) .addServiceType( STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME, ServiceMode.STREAMING_SOURCE, "Streaming sports activity feed", null) - .addServiceType( - POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME, - ServiceMode.POLLING_SOURCE, - "Polled sports activity feed snapshot", - null - ) .build(APPLICATION_TYPE, 1); } diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java index e9873bd..5972adf 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java @@ -87,7 +87,7 @@ public void onMessage(SportsActivity sportsActivity) { catch (JsonProcessingException | PayloadConversionException e) { - LOG.error("Cannot publish", e); + LOG.error("Failed to convert sports activity to JSON", e); } } } diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java index 4f8b0ec..9bdb913 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java @@ -94,7 +94,7 @@ public CompletableFuture poll() { catch (JsonProcessingException | PayloadConversionException e) { - LOG.error("Cannot publish", e); + LOG.error("Failed to convert sports activity to JSON", e); pollCf.completeExceptionally(e); } diff --git a/sports-activity-feed-adapter/src/main/resources/configuration.json b/sports-activity-feed-adapter/src/main/resources/configuration.json index c634b3c..21a46f2 100644 --- a/sports-activity-feed-adapter/src/main/resources/configuration.json +++ b/sports-activity-feed-adapter/src/main/resources/configuration.json @@ -3,7 +3,7 @@ "framework-version": 1, "application-version": 1, "diffusion": { - "url": "ws://localhost:8080", + "url": "ws://localhost:18080", "principal": "admin", "password": "password", "reconnectIntervalMs": 5000 @@ -27,23 +27,6 @@ "topicPath": "sports/activity/feed/snapshot" } } - }, - { - "serviceName": "streaming-sports-activity-feed-service-1", - "serviceType": "streaming-sports-activity-feed-service", - "config": { - "framework": { - "topicProperties": { - "topicType": "JSON", - "persistencePolicy": "SESSION", - "publishValuesOnly": false, - "dontRetainValue": false - } - }, - "application": { - "topicPrefix": "sports/activity/feed/stream" - } - } } ] } From 8872df31af67e11339a14bbbb9c48ee7ddc210e5 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Sun, 24 Nov 2024 17:35:24 +0000 Subject: [PATCH 20/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index 6792ba5..8361004 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -370,4 +370,4 @@ Note: the system property `-Dgateway.config.use-local-services=true` tells the a Once the Gateway adapter has started up, new topics should appear in Diffusion. Looking in the Diffusion console, it will look something like below: -![[polling-sports-activity-feed-in-diffusion-console.png]] +![Diffusion console showing - polling sports activity feed](polling-sports-activity-feed-in-diffusion-console.png) From 655cd3277275b212c37ddfae5c37e9a26e4481c7 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 10:08:18 +0000 Subject: [PATCH 21/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 34 ++++++++++--------- .../src/main/resources/configuration.json | 19 ++++++++++- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index 8361004..ac71863 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -12,7 +12,7 @@ of Diffusion. ## How to run the Activity feed Gateway adapter - java -Dgateway.config.file=sportsActivity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\sportsActivity-feed-adapter\target\sportsActivity-feed-adapter-1.0.0-jar-with-dependencies.jar + java -Dgateway.config.file=sports-activity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\sports-activity-feed-adapter\target\sports-activity-feed-adapter-1.0.0-jar-with-dependencies.jar --- @@ -26,9 +26,9 @@ After completing the tutorial, you can expect to understand how the Gateway fram ## Overview This example uses the concept of a sporting activity feed (think along the lines of popular exercise/social networks). Naturally, we don't build the platform for this tutorial; instead, we use a pretend sports activity feed server that generates realistic random sports activity data. -The pretend sports activity feed server provides a client API that allows an application to subscribe to a feed of activities, with the changes pushed to the subscribed clients as they happen - so, this would be like someone completing an sports activity, uploading it and then the sports activity is sent as an event to subscribers. Additionally, the pretend sports activity feed client API has a mechanism for requesting a snapshot of the latest activities at a point in time. +The pretend sports activity feed server provides a client API that allows an application to subscribe to a feed of activities, with the changes pushed to the subscribed clients as they happen - so, this would be like someone completing a sports activity, uploading it and then the sports activity is sent as an event to subscribers. Additionally, the pretend sports activity feed client API has a mechanism for requesting a snapshot of the latest activities at a point in time. -In this tutorial, we'll integrate the Gateway Framework with the pretend sports activity feed server and demonstrate data streaming into the Gateway adapter and polling to receive the sports activity snapshot. +In this tutorial, we'll integrate the Gateway Framework with the pretend sports activity feed server, demonstrate data streaming into the Gateway adapter and poll the data to receive the sports activity snapshot. The final solution comprises the following: - Pretend sports activity feed server - this provides a client API for receiving streaming events and the ability to request a snapshot of the latest activities. @@ -36,7 +36,7 @@ The final solution comprises the following: - Diffusion server - this is a running instance of Diffusion; you can run Diffusion in several different ways, such as running locally on your machine or connecting to a remote Diffusion server. The sports activity domain object has the following attributes: -- **Sport:** the sporting activity, such as swimming, sailing, tennis and other sports. +- **Sport:** the sporting activity includes swimming, sailing, tennis and other sports. - **Country:** the country where the sports activity took place. - **Winner:** the name of the person who won the sporting activity. - **Date of activity:** when the sporting activity took place. @@ -80,9 +80,9 @@ To get started with the sports activity feed example, you will need the followin - Install Diffusion via the standard Diffusion installer. - Use the Diffusion Docker image to run a container. - Use Diffusion Cloud, the DiffusionData SaaS offering. - - Connect to a Diffusion server running remotely. + - Connect to a Diffusion server that is running remotely. -The sports activity feed example code is available on GitHub and is part of the overall Gateway examples project: +The Sports activity feed example code is available on GitHub and is part of the overall Gateway examples project: * [diffusiondata/gateway-examples](https://github.com/diffusiondata/gateway-examples) Follow the README file within the sports-activity-feed-adapter module to start building the project and running the example. @@ -93,20 +93,20 @@ Developing the sports activity feed Gateway adapter requires very little code an - A class that implements the `PollingSourceHandler` interface. - A class that implements the `StreamingSourceHandler` interface. - Simple Gateway adapter runner. -- Create a Gateway adapter configuration file that will be used to configure our streaming and polling handlers. +- Create a Gateway adapter configuration file to configure the streaming and polling handlers. -Note: the code is available in GitHub, so you may find referring to the completed solution helpful. +Note: the example code is available in GitHub, so referring to the completed solution may be helpful. ### Gateway application class -Firstly, create a class called `SportsActivityFeedGatewayApplication` that implements the `GatewayApplication` interface. The class is a standard way of writing Gateway adapters. An adapter can then have different types of `ServiceHandler` for handling streaming, polling or sinking data against your chosen datasources. You will need to implement a few methods, such as: +Firstly, create a class called `SportsActivityFeedGatewayApplication` that implements the `GatewayApplication` interface. The class is a standard way of writing Gateway adapters. An adapter can have different types of `ServiceHandler` for handling streaming, polling or sinking data against your chosen datasources. You will need to implement a few methods, such as: - `getApplicationDetails` - provides details of the adapter, such as which types of `ServiceHandler` are available and can be configured. -- `stop` - called when the Gateway adapter is shutdown. +- `stop` - called when the Gateway adapter shuts down. As we go through the tutorial, you will need to override two methods: - `addPollingSource` - adds a polling source to the adapter. - `addStreamingSource` - adds a streaming source to the adapter. -Because our Gateway adapter will integrate with the pretend sports activity feed server, we'll pass an `SportsActivityFeedClient` reference in the constructor for later use by the streaming and polling service handlers. The `ObjectMapper` is used to convert our SportsActivity object into JSON. Our code will initially look something like: +Because our Gateway adapter will integrate with the pretend sports activity feed server, we'll pass a `SportsActivityFeedClient` reference in the constructor for later use by the streaming and polling service handlers. The `ObjectMapper` is used to convert our SportsActivity object into JSON. Our code will initially look something like: ```java public final class SportsActivityFeedGatewayApplication @@ -156,7 +156,7 @@ public final class SportsActivityFeedGatewayApplication }``` ### Gateway application runner class -Create a new class called `Runner` - a simple Java class with a main method; this is a typical idiom Gateway adapters use for launching the Gateway application. +Create a new class called `Runner` - a simple Java class with a `main` method; this is a typical idiom Gateway adapters use for launching the Gateway application. ```java public final class Runner { @@ -277,7 +277,7 @@ public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl ``` #### Add polling service to the Gateway application class -Now you have created the polling service class, we can add it to the `getApplicationDetails` as a supported service type and then include the code within the `addPollingSource` method that will instantiate an instance of the polling source handler class. +Now you have created the polling service class, we can add it to the `getApplicationDetails` as a supported service type and then include the code within the `addPollingSource` method that will instantiate an instance of the polling source handler class: ```java @Override @@ -360,7 +360,7 @@ Note: change the Diffusion URL, principal and password to match what is required Note: the service type "polling-sports-activity-feed-service" is how the configuration is linked to the code in the `getApplicationDetails` method. #### Run the adapter with the polling service added -After building the project, you can run the sports activity feed Gateway adapter from the root of Gateway examples project: +After building the project, you can run the sports activity feed Gateway adapter from the root of the Gateway examples project: ```shell java -Dgateway.config.file=sports-activity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\sports-activity-feed-adapter\target\sports-activity-feed-adapter-1.0.0-jar-with-dependencies.jar @@ -368,6 +368,8 @@ java -Dgateway.config.file=sports-activity-feed-adapter/src/main/resources/confi Note: the system property `-Dgateway.config.use-local-services=true` tells the adapter to use the configuration that is specified in the configuration file and not to use any configuration cached in the Diffusion server. -Once the Gateway adapter has started up, new topics should appear in Diffusion. Looking in the Diffusion console, it will look something like below: +Once the Gateway adapter has started, new topics should appear in Diffusion. Looking in the Diffusion console, it will look something like below: -![Diffusion console showing - polling sports activity feed](polling-sports-activity-feed-in-diffusion-console.png) +![[polling-sports-activity-feed-in-diffusion-console.png]] + +You should now have a running sports activity feed Gateway adapter polling the pretend sports activity feed server. diff --git a/sports-activity-feed-adapter/src/main/resources/configuration.json b/sports-activity-feed-adapter/src/main/resources/configuration.json index 21a46f2..c634b3c 100644 --- a/sports-activity-feed-adapter/src/main/resources/configuration.json +++ b/sports-activity-feed-adapter/src/main/resources/configuration.json @@ -3,7 +3,7 @@ "framework-version": 1, "application-version": 1, "diffusion": { - "url": "ws://localhost:18080", + "url": "ws://localhost:8080", "principal": "admin", "password": "password", "reconnectIntervalMs": 5000 @@ -27,6 +27,23 @@ "topicPath": "sports/activity/feed/snapshot" } } + }, + { + "serviceName": "streaming-sports-activity-feed-service-1", + "serviceType": "streaming-sports-activity-feed-service", + "config": { + "framework": { + "topicProperties": { + "topicType": "JSON", + "persistencePolicy": "SESSION", + "publishValuesOnly": false, + "dontRetainValue": false + } + }, + "application": { + "topicPrefix": "sports/activity/feed/stream" + } + } } ] } From c78f6768d3e89996a2f5cb9c3cd234c19c1c2063 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 10:42:02 +0000 Subject: [PATCH 22/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 10 ++++---- .../SportsActivityFeedGatewayApplication.java | 24 +++++++++---------- ...ortsActivityFeedPollingSourceHandler.java} | 6 ++--- ...tsActivityFeedStreamingSourceHandler.java} | 6 ++--- .../src/main/resources/configuration.json | 8 +++---- ...rtsActivityFeedGatewayApplicationTest.java | 14 +++++------ ...ActivityFeedPollingSourceHandlerTest.java} | 8 +++---- ...tivityFeedStreamingSourceHandlerTest.java} | 10 ++++---- 8 files changed, 43 insertions(+), 43 deletions(-) rename sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/{SportsActivityFeedSnapshotPollingSourceHandlerImpl.java => SportsActivityFeedPollingSourceHandler.java} (95%) rename sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/{SportsActivityFeedListenerStreamingSourceHandlerImpl.java => SportsActivityFeedStreamingSourceHandler.java} (95%) rename sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/{SportsActivityFeedSnapshotPollingSourceHandlerImplTest.java => SportsActivityFeedPollingSourceHandlerTest.java} (96%) rename sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/{SportsActivityFeedListenerStreamingSourceHandlerImplTest.java => SportsActivityFeedStreamingSourceHandlerTest.java} (95%) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index ac71863..bbb380a 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -173,7 +173,7 @@ public final class Runner { ``` ### Polling source handler class and configuration -Create a class called `SportsActivityFeedSnapshotPollingSourceHandlerImpl` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend sports activity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: +Create a class called `SportsActivityFeedPollingSourceHandler` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend sports activity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: - `poll` - this method is periodically called by the Gateway framework based on configuration. - `pause` - called when the Gateway adapter enters the paused state. - `resume` - is called when the Gateway adapter can resume. @@ -181,7 +181,7 @@ Create a class called `SportsActivityFeedSnapshotPollingSourceHandlerImpl` and h In your `poll` method, we will call the pretend sports activity feed server's `getSportsLatestActivities()` using the `SportsActivityFeedClient` reference passed into the constructor. Below is the complete code for the polling source handler: ```java -public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl +public final class SportsActivityFeedPollingSourceHandler implements PollingSourceHandler { static final String DEFAULT_POLLING_TOPIC_PATH = @@ -189,7 +189,7 @@ public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl private static final Logger LOG = LoggerFactory.getLogger( - SportsActivityFeedSnapshotPollingSourceHandlerImpl.class); + SportsActivityFeedPollingSourceHandler.class); private final SportsActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; @@ -197,7 +197,7 @@ public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl private final ObjectMapper objectMapper; private final String topicPath; - public SportsActivityFeedSnapshotPollingSourceHandlerImpl( + public SportsActivityFeedPollingSourceHandler( SportsActivityFeedClient sportsActivityFeedClient, ServiceDefinition serviceDefinition, Publisher publisher, @@ -304,7 +304,7 @@ public PollingSourceHandler addPollingSource( serviceDefinition.getServiceType().getName(); if (POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { - return new SportsActivityFeedSnapshotPollingSourceHandlerImpl( + return new SportsActivityFeedPollingSourceHandler( sportsActivityFeedClient, serviceDefinition, publisher, diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java index cda9c3d..674324e 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java @@ -29,11 +29,11 @@ public final class SportsActivityFeedGatewayApplication static final String APPLICATION_TYPE = "sports-activity-feed-application"; - static final String STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME = - "streaming-sports-activity-feed-service"; + static final String SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME = + "SPORTS_ACTIVITY_FEED_STREAMER"; - static final String POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME = - "polling-sports-activity-feed-service"; + static final String SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME = + "SPORTS_ACTIVITY_FEED_POLLER"; private static final Logger LOG = LoggerFactory.getLogger(SportsActivityFeedGatewayApplication.class); @@ -59,14 +59,14 @@ public ApplicationDetails getApplicationDetails() return DiffusionGatewayFramework.newApplicationDetailsBuilder() .addServiceType( - POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME, + SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME, ServiceMode.POLLING_SOURCE, - "Polled sports activity feed snapshot", + "Polls the sports activity feed at a regular interval", null) .addServiceType( - STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME, + SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME, ServiceMode.STREAMING_SOURCE, - "Streaming sports activity feed", + "Streams the sports activity feed as they are available", null) .build(APPLICATION_TYPE, 1); } @@ -81,8 +81,8 @@ public StreamingSourceHandler addStreamingSource( final String serviceType = serviceDefinition.getServiceType().getName(); - if (STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { - return new SportsActivityFeedListenerStreamingSourceHandlerImpl( + if (SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME.equals(serviceType)) { + return new SportsActivityFeedStreamingSourceHandler( sportsActivityFeedClient, serviceDefinition, publisher, @@ -104,8 +104,8 @@ public PollingSourceHandler addPollingSource( final String serviceType = serviceDefinition.getServiceType().getName(); - if (POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { - return new SportsActivityFeedSnapshotPollingSourceHandlerImpl( + if (SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME.equals(serviceType)) { + return new SportsActivityFeedPollingSourceHandler( sportsActivityFeedClient, serviceDefinition, publisher, diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandler.java similarity index 95% rename from sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandler.java index 9bdb913..95a88db 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandler.java @@ -22,7 +22,7 @@ import net.jcip.annotations.ThreadSafe; @ThreadSafe -public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl +public final class SportsActivityFeedPollingSourceHandler implements PollingSourceHandler { static final String DEFAULT_POLLING_TOPIC_PATH = @@ -30,7 +30,7 @@ public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl private static final Logger LOG = LoggerFactory.getLogger( - SportsActivityFeedSnapshotPollingSourceHandlerImpl.class); + SportsActivityFeedPollingSourceHandler.class); private final SportsActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; @@ -38,7 +38,7 @@ public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl private final ObjectMapper objectMapper; private final String topicPath; - public SportsActivityFeedSnapshotPollingSourceHandlerImpl( + public SportsActivityFeedPollingSourceHandler( SportsActivityFeedClient sportsActivityFeedClient, ServiceDefinition serviceDefinition, Publisher publisher, diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandler.java similarity index 95% rename from sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java rename to sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandler.java index 5972adf..82ac953 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImpl.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandler.java @@ -23,7 +23,7 @@ import net.jcip.annotations.ThreadSafe; @ThreadSafe -public final class SportsActivityFeedListenerStreamingSourceHandlerImpl +public final class SportsActivityFeedStreamingSourceHandler implements SportsActivityFeedListener, StreamingSourceHandler { @@ -32,7 +32,7 @@ public final class SportsActivityFeedListenerStreamingSourceHandlerImpl private static final Logger LOG = LoggerFactory.getLogger( - SportsActivityFeedListenerStreamingSourceHandlerImpl.class); + SportsActivityFeedStreamingSourceHandler.class); private final SportsActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; @@ -43,7 +43,7 @@ public final class SportsActivityFeedListenerStreamingSourceHandlerImpl @GuardedBy("this") private String listenerIdentifier; - public SportsActivityFeedListenerStreamingSourceHandlerImpl( + public SportsActivityFeedStreamingSourceHandler( SportsActivityFeedClient sportsActivityFeedClient, ServiceDefinition serviceDefinition, Publisher publisher, diff --git a/sports-activity-feed-adapter/src/main/resources/configuration.json b/sports-activity-feed-adapter/src/main/resources/configuration.json index c634b3c..457858f 100644 --- a/sports-activity-feed-adapter/src/main/resources/configuration.json +++ b/sports-activity-feed-adapter/src/main/resources/configuration.json @@ -10,8 +10,8 @@ }, "services": [ { - "serviceName": "polling-sports-activity-feed-service-1", - "serviceType": "polling-sports-activity-feed-service", + "serviceName": "sportsActivityFeedPoller", + "serviceType": "SPORTS_ACTIVITY_FEED_POLLER", "config": { "framework": { "pollIntervalMs": 4500, @@ -29,8 +29,8 @@ } }, { - "serviceName": "streaming-sports-activity-feed-service-1", - "serviceType": "streaming-sports-activity-feed-service", + "serviceName": "sportsActivityFeedStreamer", + "serviceType": "SPORTS_ACTIVITY_FEED_STREAMER", "config": { "framework": { "topicProperties": { diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplicationTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplicationTest.java index 9f3a510..007399c 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplicationTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplicationTest.java @@ -1,9 +1,9 @@ package com.diffusiondata.gateway.example.sportsactivity.feed; import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.APPLICATION_TYPE; -import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME; -import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME; -import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedGatewayApplication.SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedStreamingSourceHandler.DEFAULT_STREAMING_TOPIC_PREFIX; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.collection.IsIterableWithSize.iterableWithSize; import static org.hamcrest.core.IsEqual.equalTo; @@ -102,7 +102,7 @@ void testAddStreamingSourceWhenServiceTypeExists() .thenReturn(serviceTypeMock); when(serviceTypeMock.getName()) - .thenReturn(STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME); + .thenReturn(SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME); when(serviceDefinitionMock.getParameters()) .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); @@ -115,7 +115,7 @@ void testAddStreamingSourceWhenServiceTypeExists() assertThat(handler, notNullValue()); assertThat(handler, - instanceOf(SportsActivityFeedListenerStreamingSourceHandlerImpl.class)); + instanceOf(SportsActivityFeedStreamingSourceHandler.class)); } @Test @@ -142,7 +142,7 @@ void testAddPollingSourceWhenServiceTypeExists() .thenReturn(serviceTypeMock); when(serviceTypeMock.getName()) - .thenReturn(POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME); + .thenReturn(SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME); when(serviceDefinitionMock.getParameters()) .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); @@ -155,7 +155,7 @@ void testAddPollingSourceWhenServiceTypeExists() assertThat(handler, notNullValue()); assertThat(handler, - instanceOf(SportsActivityFeedSnapshotPollingSourceHandlerImpl.class)); + instanceOf(SportsActivityFeedPollingSourceHandler.class)); } @Test diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandlerTest.java similarity index 96% rename from sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImplTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandlerTest.java index 6cc3c3d..bc6e5a3 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedSnapshotPollingSourceHandlerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandlerTest.java @@ -1,6 +1,6 @@ package com.diffusiondata.gateway.example.sportsactivity.feed; -import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedSnapshotPollingSourceHandlerImpl.DEFAULT_POLLING_TOPIC_PATH; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedPollingSourceHandler.DEFAULT_POLLING_TOPIC_PATH; import static com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils.createPopulatedSportsActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsEqual.equalTo; @@ -41,7 +41,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) -class SportsActivityFeedSnapshotPollingSourceHandlerImplTest { +class SportsActivityFeedPollingSourceHandlerTest { @Mock private SportsActivityFeedClient sportsActivityFeedClientMock; @@ -64,7 +64,7 @@ void beforeEachTest() { when(serviceDefinitionMock.getParameters()) .thenReturn(Map.of("topicPrefix", DEFAULT_POLLING_TOPIC_PATH)); - handler = new SportsActivityFeedSnapshotPollingSourceHandlerImpl( + handler = new SportsActivityFeedPollingSourceHandler( sportsActivityFeedClientMock, serviceDefinitionMock, publisherMock, @@ -72,7 +72,7 @@ void beforeEachTest() { objectMapperMock); final String topicPrefix = - ((SportsActivityFeedSnapshotPollingSourceHandlerImpl) handler) + ((SportsActivityFeedPollingSourceHandler) handler) .getTopicPath(); assertThat(topicPrefix, notNullValue()); diff --git a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImplTest.java b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandlerTest.java similarity index 95% rename from sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImplTest.java rename to sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandlerTest.java index b34b874..033788f 100644 --- a/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedListenerStreamingSourceHandlerImplTest.java +++ b/sports-activity-feed-adapter/src/test/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandlerTest.java @@ -1,6 +1,6 @@ package com.diffusiondata.gateway.example.sportsactivity.feed; -import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedListenerStreamingSourceHandlerImpl.DEFAULT_STREAMING_TOPIC_PREFIX; +import static com.diffusiondata.gateway.example.sportsactivity.feed.SportsActivityFeedStreamingSourceHandler.DEFAULT_STREAMING_TOPIC_PREFIX; import static com.diffusiondata.pretend.example.sportsactivity.feed.model.SportsActivityTestUtils.createPopulatedSportsActivity; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.IsEqual.equalTo; @@ -37,7 +37,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) -class SportsActivityFeedListenerStreamingSourceHandlerImplTest { +class SportsActivityFeedStreamingSourceHandlerTest { @Mock private SportsActivityFeedClient sportsActivityFeedClientMock; @@ -61,7 +61,7 @@ void beforeEachTest() { .thenReturn(Map.of("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX)); - handler = new SportsActivityFeedListenerStreamingSourceHandlerImpl( + handler = new SportsActivityFeedStreamingSourceHandler( sportsActivityFeedClientMock, serviceDefinitionMock, publisherMock, @@ -233,12 +233,12 @@ private String invokeStart() { } private String getImplsTopicPrefix() { - return ((SportsActivityFeedListenerStreamingSourceHandlerImpl) handler) + return ((SportsActivityFeedStreamingSourceHandler) handler) .getTopicPrefix(); } private String getImplsListenerIdentifier() { - return ((SportsActivityFeedListenerStreamingSourceHandlerImpl) handler) + return ((SportsActivityFeedStreamingSourceHandler) handler) .getListenerIdentifier(); } } From 7d42f5792e57e3feb1916b215d13bee31c80514b Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 10:47:42 +0000 Subject: [PATCH 23/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 4 +++- .../feed/SportsActivityFeedPollingSourceHandler.java | 4 +++- .../feed/SportsActivityFeedStreamingSourceHandler.java | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index bbb380a..e889fc2 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -253,7 +253,9 @@ public final class SportsActivityFeedPollingSourceHandler catch (JsonProcessingException | PayloadConversionException e) { - LOG.error("Failed to convert sports activity to JSON", e); + LOG.error( + "Failed to convert sports activity to configured type", e); + pollCf.completeExceptionally(e); } diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandler.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandler.java index 95a88db..da6fa0f 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandler.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedPollingSourceHandler.java @@ -94,7 +94,9 @@ public CompletableFuture poll() { catch (JsonProcessingException | PayloadConversionException e) { - LOG.error("Failed to convert sports activity to JSON", e); + LOG.error( + "Failed to convert sports activity to configured type", e); + pollCf.completeExceptionally(e); } diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandler.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandler.java index 82ac953..8ba94af 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandler.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedStreamingSourceHandler.java @@ -87,7 +87,8 @@ public void onMessage(SportsActivity sportsActivity) { catch (JsonProcessingException | PayloadConversionException e) { - LOG.error("Failed to convert sports activity to JSON", e); + LOG.error( + "Failed to convert sports activity to configured type", e); } } } From d2a75351809cb1f1fa2db6b8a0410fad29158e89 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 10:49:05 +0000 Subject: [PATCH 24/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index e889fc2..af5ae88 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -153,7 +153,8 @@ public final class SportsActivityFeedGatewayApplication return CompletableFuture.completedFuture(null); } -}``` +} +``` ### Gateway application runner class Create a new class called `Runner` - a simple Java class with a `main` method; this is a typical idiom Gateway adapters use for launching the Gateway application. From c171f6c319deee6146cea499a077e699a00c7aed Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 14:26:27 +0000 Subject: [PATCH 25/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index af5ae88..c6d7bc2 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -373,6 +373,6 @@ Note: the system property `-Dgateway.config.use-local-services=true` tells the a Once the Gateway adapter has started, new topics should appear in Diffusion. Looking in the Diffusion console, it will look something like below: -![[polling-sports-activity-feed-in-diffusion-console.png]] +![Polled sports activity feed in Diffusion console](polling-sports-activity-feed-in-diffusion-console.png) You should now have a running sports activity feed Gateway adapter polling the pretend sports activity feed server. From 2ed2cc50fd437b4700b651a3b02e51da7b8ab52e Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 15:24:46 +0000 Subject: [PATCH 26/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 321 ++++++++++++++++-- ...ts-activity-feeds-in-diffusion-console.png | Bin 0 -> 132048 bytes 2 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 sports-activity-feed-adapter/polling-and-streaming-sports-activity-feeds-in-diffusion-console.png diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index c6d7bc2..cb99750 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -153,8 +153,7 @@ public final class SportsActivityFeedGatewayApplication return CompletableFuture.completedFuture(null); } -} -``` +}``` ### Gateway application runner class Create a new class called `Runner` - a simple Java class with a `main` method; this is a typical idiom Gateway adapters use for launching the Gateway application. @@ -174,7 +173,7 @@ public final class Runner { ``` ### Polling source handler class and configuration -Create a class called `SportsActivityFeedPollingSourceHandler` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend sports activity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: +Create a class called `SportsActivityFeedSnapshotPollingSourceHandlerImpl` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend sports activity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: - `poll` - this method is periodically called by the Gateway framework based on configuration. - `pause` - called when the Gateway adapter enters the paused state. - `resume` - is called when the Gateway adapter can resume. @@ -182,7 +181,7 @@ Create a class called `SportsActivityFeedPollingSourceHandler` and have it imple In your `poll` method, we will call the pretend sports activity feed server's `getSportsLatestActivities()` using the `SportsActivityFeedClient` reference passed into the constructor. Below is the complete code for the polling source handler: ```java -public final class SportsActivityFeedPollingSourceHandler +public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl implements PollingSourceHandler { static final String DEFAULT_POLLING_TOPIC_PATH = @@ -190,7 +189,7 @@ public final class SportsActivityFeedPollingSourceHandler private static final Logger LOG = LoggerFactory.getLogger( - SportsActivityFeedPollingSourceHandler.class); + SportsActivityFeedSnapshotPollingSourceHandlerImpl.class); private final SportsActivityFeedClient sportsActivityFeedClient; private final Publisher publisher; @@ -198,7 +197,7 @@ public final class SportsActivityFeedPollingSourceHandler private final ObjectMapper objectMapper; private final String topicPath; - public SportsActivityFeedPollingSourceHandler( + public SportsActivityFeedSnapshotPollingSourceHandlerImpl( SportsActivityFeedClient sportsActivityFeedClient, ServiceDefinition serviceDefinition, Publisher publisher, @@ -254,9 +253,7 @@ public final class SportsActivityFeedPollingSourceHandler catch (JsonProcessingException | PayloadConversionException e) { - LOG.error( - "Failed to convert sports activity to configured type", e); - + LOG.error("Failed to convert sports activity to JSON", e); pollCf.completeExceptionally(e); } @@ -289,13 +286,13 @@ public ApplicationDetails getApplicationDetails() return DiffusionGatewayFramework.newApplicationDetailsBuilder() .addServiceType( - POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME, + SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME, ServiceMode.POLLING_SOURCE, - "Polled sports activity feed snapshot", + "Polls the sports activity feed at a regular interval", null) .build(APPLICATION_TYPE, 1); -} - +} + @Override public PollingSourceHandler addPollingSource( ServiceDefinition serviceDefinition, @@ -306,7 +303,7 @@ public PollingSourceHandler addPollingSource( final String serviceType = serviceDefinition.getServiceType().getName(); - if (POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME.equals(serviceType)) { + if (SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME.equals(serviceType)) { return new SportsActivityFeedPollingSourceHandler( sportsActivityFeedClient, serviceDefinition, @@ -336,8 +333,8 @@ The code for the polling source handler is now complete, so you need to configur }, "services": [ { - "serviceName": "polling-sports-activity-feed-service-1", - "serviceType": "polling-sports-activity-feed-service", + "serviceName": "sportsActivityFeedPoller", + "serviceType": "SPORTS_ACTIVITY_FEED_POLLER", "config": { "framework": { "pollIntervalMs": 4500, @@ -353,8 +350,8 @@ The code for the polling source handler is now complete, so you need to configur "topicPath": "sports/activity/feed/snapshot" } } - } - ] + } + ] } ``` @@ -369,10 +366,292 @@ After building the project, you can run the sports activity feed Gateway adapter java -Dgateway.config.file=sports-activity-feed-adapter/src/main/resources/configuration.json -Dgateway.config.use-local-services=true -jar .\sports-activity-feed-adapter\target\sports-activity-feed-adapter-1.0.0-jar-with-dependencies.jar ``` -Note: the system property `-Dgateway.config.use-local-services=true` tells the adapter to use the configuration that is specified in the configuration file and not to use any configuration cached in the Diffusion server. +Note: the system property `-Dgateway.config.use-local-services=true` tells the adapter to use the configuration specified in the configuration file and not to use any configuration cached in the Diffusion server. Once the Gateway adapter has started, new topics should appear in Diffusion. Looking in the Diffusion console, it will look something like below: -![Polled sports activity feed in Diffusion console](polling-sports-activity-feed-in-diffusion-console.png) +![Polling sports activity feed in Diffusion console](polling-sports-activity-feed-in-diffusion-console.png) + +You should now have a running sports activity feed Gateway adapter polling the pretend sports activity feed server and populating Diffusion topics. + +### Streaming source handler class and configuration +Create a class called `SportsActivityFeedStreamingSourceHandler`, which implements the `StreamingSourceHandler` interface from the Gateway framework and the `SportsActivityFeedListener` interface available with the pretend sports activity feed server. For our example, this will handle the activities streamed from the pretend sports activity feed server and put the data into Diffusion topics: + +```java +public final class SportsActivityFeedStreamingSourceHandler + implements SportsActivityFeedListener, + StreamingSourceHandler { + + static final String DEFAULT_STREAMING_TOPIC_PREFIX = + "default/sports/activity/feed/stream"; + + private static final Logger LOG = + LoggerFactory.getLogger( + SportsActivityFeedStreamingSourceHandler.class); + + private final SportsActivityFeedClient sportsActivityFeedClient; + private final Publisher publisher; + private final StateHandler stateHandler; + private final ObjectMapper objectMapper; + private final String topicPrefix; + + private String listenerIdentifier; + + public SportsActivityFeedStreamingSourceHandler( + SportsActivityFeedClient sportsActivityFeedClient, + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler, + ObjectMapper objectMapper) { + + this.sportsActivityFeedClient = + requireNonNull(sportsActivityFeedClient, + "sportsActivityFeedClient"); + + this.publisher = requireNonNull(publisher, "publisher"); + this.stateHandler = requireNonNull(stateHandler, "stateHandler"); + requireNonNull(serviceDefinition, "serviceDefinition"); + this.objectMapper = requireNonNull(objectMapper, "objectMapper"); + + topicPrefix = serviceDefinition.getParameters() + .getOrDefault("topicPrefix", DEFAULT_STREAMING_TOPIC_PREFIX) + .toString(); + } + + @Override + public void onMessage(SportsActivity sportsActivity) { + requireNonNull(sportsActivity, "sportsActivity"); + + if (stateHandler.getState().equals(ServiceState.ACTIVE)) { + try { + final String topicPath = topicPrefix + "/" + + sportsActivity.getSport(); + + final String value = + objectMapper.writeValueAsString(sportsActivity); + + publisher.publish(topicPath, value) + .exceptionally(throwable -> { + LOG.error("Cannot publish to topic: '{}'", + topicPath, throwable); + + return null; + }); + } + catch (JsonProcessingException | + PayloadConversionException e) { + + LOG.error( + "Failed to convert sports activity to configured type", e); + } + } + } + + @Override + public synchronized CompletableFuture start() { + listenerIdentifier = + sportsActivityFeedClient.registerListener(this); + + LOG.info("Started sports activity feed streaming handler"); + + return CompletableFuture.completedFuture(null); + } + + @Override + public synchronized CompletableFuture stop() { + sportsActivityFeedClient.unregisterListener(listenerIdentifier); + listenerIdentifier = null; + + LOG.info("Stopped sports activity feed streaming handler"); + + return CompletableFuture.completedFuture(null); + } + + @Override + public synchronized CompletableFuture pause(PauseReason reason) { + sportsActivityFeedClient.unregisterListener(listenerIdentifier); + listenerIdentifier = null; + + LOG.info("Paused sports activity feed streaming handler"); + + return CompletableFuture.completedFuture(null); + } + + @Override + public synchronized CompletableFuture resume(ResumeReason reason) { + listenerIdentifier = + sportsActivityFeedClient.registerListener(this); + + LOG.info("Resumed sports activity feed streaming handler"); + + return CompletableFuture.completedFuture(null); + } +} +``` + +#### Add streaming service to the Gateway application class +Now you have created the streaming service class, we can add it to the `getApplicationDetails` as a supported service type and then include the code within the `addStreamingSource` method that will instantiate an instance of the streaming source handler class. The completed Gateway application class looks like this: + +```java +public final class SportsActivityFeedGatewayApplication + implements GatewayApplication { + + static final String APPLICATION_TYPE = + "sports-activity-feed-application"; + + static final String SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME = + "SPORTS_ACTIVITY_FEED_STREAMER"; + + static final String SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME = + "SPORTS_ACTIVITY_FEED_POLLER"; + + private static final Logger LOG = + LoggerFactory.getLogger(SportsActivityFeedGatewayApplication.class); + + private final SportsActivityFeedClient sportsActivityFeedClient; + private final ObjectMapper objectMapper; + + public SportsActivityFeedGatewayApplication( + SportsActivityFeedClient sportsActivityFeedClient, + ObjectMapper objectMapper) { + + this.sportsActivityFeedClient = + requireNonNull(sportsActivityFeedClient, + "sportsActivityFeedClient"); + + this.objectMapper = + requireNonNull(objectMapper, "objectMapper"); + } + + @Override + public ApplicationDetails getApplicationDetails() + throws ApplicationConfigurationException { + + return DiffusionGatewayFramework.newApplicationDetailsBuilder() + .addServiceType( + SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME, + ServiceMode.POLLING_SOURCE, + "Polls the sports activity feed at a regular interval", + null) + .addServiceType( + SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME, + ServiceMode.STREAMING_SOURCE, + "Streams the sports activity feed as they are available", + null) + .build(APPLICATION_TYPE, 1); + } + + @Override + public StreamingSourceHandler addStreamingSource( + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler) + throws InvalidConfigurationException { + + final String serviceType = + serviceDefinition.getServiceType().getName(); + + if (SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME.equals(serviceType)) { + return new SportsActivityFeedStreamingSourceHandler( + sportsActivityFeedClient, + serviceDefinition, + publisher, + stateHandler, + objectMapper); + } + + throw new InvalidConfigurationException( + "Unknown service type: " + serviceType); + } + + @Override + public PollingSourceHandler addPollingSource( + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler) + throws InvalidConfigurationException { + + final String serviceType = + serviceDefinition.getServiceType().getName(); + + if (SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME.equals(serviceType)) { + return new SportsActivityFeedPollingSourceHandler( + sportsActivityFeedClient, + serviceDefinition, + publisher, + stateHandler, + objectMapper); + } + + throw new InvalidConfigurationException( + "Unknown service type: " + serviceType); + } + + @Override + public CompletableFuture stop() { + LOG.info("Application stop"); + + return CompletableFuture.completedFuture(null); + } +} +``` + +#### Update the configuration file with the streaming service instance +Finally, you need to add the streaming source to the configuration. The complete configuration file is as per below: + +```json +{ + "id": "sports-activity-feed-adapter-1", + "framework-version": 1, + "application-version": 1, + "diffusion": { + "url": "ws://localhost:8080", + "principal": "admin", + "password": "password", + "reconnectIntervalMs": 5000 + }, + "services": [ + { + "serviceName": "sportsActivityFeedPoller", + "serviceType": "SPORTS_ACTIVITY_FEED_POLLER", + "config": { + "framework": { + "pollIntervalMs": 4500, + "pollTimeoutMs": 300000, + "topicProperties": { + "topicType": "JSON", + "persistencePolicy": "SESSION", + "publishValuesOnly": false, + "dontRetainValue": false + } + }, + "application": { + "topicPath": "sports/activity/feed/snapshot" + } + } + }, + { + "serviceName": "sportsActivityFeedStreamer", + "serviceType": "SPORTS_ACTIVITY_FEED_STREAMER", + "config": { + "framework": { + "topicProperties": { + "topicType": "JSON", + "persistencePolicy": "SESSION", + "publishValuesOnly": false, + "dontRetainValue": false + } + }, + "application": { + "topicPrefix": "sports/activity/feed/stream" + } + } + } + ] +} +``` + +#### Run the adapter with both services +You can now build and run the entire Gateway adapter. It will periodically poll for a snapshot of the latest activities and handle the changes in streaming sports activity as they occur. With both the streaming and polling running, you should have a topic tree in the Diffusion console similar to the one below: -You should now have a running sports activity feed Gateway adapter polling the pretend sports activity feed server. +![Polled and streaming sports activity feed in Diffusion console](polling-and-streaming-sports-activity-feeds-in-diffusion-console.png) diff --git a/sports-activity-feed-adapter/polling-and-streaming-sports-activity-feeds-in-diffusion-console.png b/sports-activity-feed-adapter/polling-and-streaming-sports-activity-feeds-in-diffusion-console.png new file mode 100644 index 0000000000000000000000000000000000000000..1669337b528b1fdc3001199832b8d9eeac548154 GIT binary patch literal 132048 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 A9{>OV literal 0 HcmV?d00001 From 3c39ec863b4fe435a6b9604a1278d48a0e219551 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 16:11:37 +0000 Subject: [PATCH 27/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 14 +++++++++++++- .../feed/SportsActivityFeedGatewayApplication.java | 6 +++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index cb99750..759ef5e 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -536,7 +536,7 @@ public final class SportsActivityFeedGatewayApplication .addServiceType( SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME, ServiceMode.STREAMING_SOURCE, - "Streams the sports activity feed as they are available", + "Streams the sports activities as they are available", null) .build(APPLICATION_TYPE, 1); } @@ -655,3 +655,15 @@ Finally, you need to add the streaming source to the configuration. The complet You can now build and run the entire Gateway adapter. It will periodically poll for a snapshot of the latest activities and handle the changes in streaming sports activity as they occur. With both the streaming and polling running, you should have a topic tree in the Diffusion console similar to the one below: ![Polled and streaming sports activity feed in Diffusion console](polling-and-streaming-sports-activity-feeds-in-diffusion-console.png) + +## Conclusion +You have now built a fully functioning Gateway adapter that handles both streaming and polled/non-streaming data with relatively few lines of code and just a little configuration. Hopefully, This tutorial has given you enough knowledge to get you started developing your own Gateway adapters. There is a lot more to explore with the Gateway framework, such as: +- Develop a sink handler that receives streaming data from Diffusion and sends it to your chosen datasources/system. +- Prometheus metrics. +- Controlling the Gateway adapter through the Diffusion console (pause, resume and stop). + +Several prebuilt Gateway adapters are ready and require configuration to connect to various datasources. The existing DiffusionData Gateway adapters are: +- Kafka Adapter +- CDC Adapter +- REST Adapter +- Redis Adapter diff --git a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java index 674324e..b5ab66f 100644 --- a/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java +++ b/sports-activity-feed-adapter/src/main/java/com/diffusiondata/gateway/example/sportsactivity/feed/SportsActivityFeedGatewayApplication.java @@ -62,12 +62,12 @@ public ApplicationDetails getApplicationDetails() SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME, ServiceMode.POLLING_SOURCE, "Polls the sports activity feed at a regular interval", - null) + null) // For this example we won't use schema validation .addServiceType( SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME, ServiceMode.STREAMING_SOURCE, - "Streams the sports activity feed as they are available", - null) + "Streams the sports activities as they are available", + null) // For this example we won't use schema validation .build(APPLICATION_TYPE, 1); } From 75cecd2a4bebc92a4c7bffb57075453ba378edb7 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 16:25:07 +0000 Subject: [PATCH 28/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index 759ef5e..1bf7371 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -375,7 +375,7 @@ Once the Gateway adapter has started, new topics should appear in Diffusion. Lo You should now have a running sports activity feed Gateway adapter polling the pretend sports activity feed server and populating Diffusion topics. ### Streaming source handler class and configuration -Create a class called `SportsActivityFeedStreamingSourceHandler`, which implements the `StreamingSourceHandler` interface from the Gateway framework and the `SportsActivityFeedListener` interface available with the pretend sports activity feed server. For our example, this will handle the activities streamed from the pretend sports activity feed server and put the data into Diffusion topics: +Create a class called `SportsActivityFeedStreamingSourceHandler`, which implements the `StreamingSourceHandler` interface from the Gateway framework and the `SportsActivityFeedListener` interface available with the pretend sports activity feed server. For our example, this will handle the activities streamed from the pretend sports activity feed server, with the `onMessage` invoked as your handler receives each new sporting activity. Within the `onMessage` method, you use the `publisher` reference to put the data into Diffusion topics: ```java public final class SportsActivityFeedStreamingSourceHandler From 0706a6f59761044818245e63cc668b1b4d4af553 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 16:40:55 +0000 Subject: [PATCH 29/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 29 +++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index 1bf7371..81361e6 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -42,7 +42,7 @@ The sports activity domain object has the following attributes: - **Date of activity:** when the sporting activity took place. The pretend sports activity feed client has the following features: -- **Register a listener:** a sports activity feed listener instance is required, with a callback method of 'onMessage' called when a new sports activity is sent to the subscriber (in our case, the Gateway adapter). +- **Register a listener:** a sports activity feed listener instance is required, with a callback method of `onMessage` called when a new sports activity is sent to the subscriber (in our case, the Gateway adapter). - **Unregister a listener:** a way of unregistering from the sports activity feed to stop receiving updates. - **Get latest activities:** returns a snapshot list of the latest sporting activities when called. @@ -60,8 +60,8 @@ DIF("Diffusion
server"):::blue %% Edges AFS -. 1a) Send sports activity event .-> AFG -AFG -- 2a) Invoke get latest activities ---> AFS -AFS -- 2b) Return latest activities --> AFG +AFG -- 2a) Invoke get latest sports activities ---> AFS +AFS -- 2b) Return latest sports activities --> AFG AFG -- 1b) Update specific \n sports activity --> DIF AFG -- 2c) Update the \n activities snapshot ---> DIF @@ -113,15 +113,15 @@ public final class SportsActivityFeedGatewayApplication implements GatewayApplication { static final String APPLICATION_TYPE = - "sports-activity-feed-application"; - - static final String STREAMING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME = - "streaming-sports-activity-feed-service"; - - static final String POLLING_SPORTS_ACTIVITY_FEED_SERVICE_TYPE_NAME = - "polling-sports-activity-feed-service"; - - private static final Logger LOG = + "sports-activity-feed-application"; + + static final String SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME = + "SPORTS_ACTIVITY_FEED_STREAMER"; + + static final String SPORTS_ACTIVITY_FEED_POLLER_SERVICE_TYPE_NAME = + "SPORTS_ACTIVITY_FEED_POLLER"; + + private static final Logger LOG = LoggerFactory.getLogger(SportsActivityFeedGatewayApplication.class); private final SportsActivityFeedClient sportsActivityFeedClient; @@ -153,7 +153,8 @@ public final class SportsActivityFeedGatewayApplication return CompletableFuture.completedFuture(null); } -}``` +} +``` ### Gateway application runner class Create a new class called `Runner` - a simple Java class with a `main` method; this is a typical idiom Gateway adapters use for launching the Gateway application. @@ -357,7 +358,7 @@ The code for the polling source handler is now complete, so you need to configur Note: change the Diffusion URL, principal and password to match what is required to connect to your Diffusion server. -Note: the service type "polling-sports-activity-feed-service" is how the configuration is linked to the code in the `getApplicationDetails` method. +Note: the service type `"SPORTS_ACTIVITY_FEED_POLLER"` is how the configuration is linked to the code in the `getApplicationDetails` method. #### Run the adapter with the polling service added After building the project, you can run the sports activity feed Gateway adapter from the root of the Gateway examples project: From c9147f4a607b2d315f9b0c7c02347c4b87a8d4a8 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 16:43:22 +0000 Subject: [PATCH 30/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index 81361e6..e2ce463 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -535,9 +535,9 @@ public final class SportsActivityFeedGatewayApplication "Polls the sports activity feed at a regular interval", null) .addServiceType( - SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME, + SPORTS_ACTIVITY_FEED_STREAMER_SERVICE_TYPE_NAME, ServiceMode.STREAMING_SOURCE, - "Streams the sports activities as they are available", + "Streams the sports activities as they are available", null) .build(APPLICATION_TYPE, 1); } From e0d8cb8749ecbe5e9e023cd17ac4fb20bd5466f3 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 17:42:00 +0000 Subject: [PATCH 31/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 200 +++++++++++++------------ 1 file changed, 101 insertions(+), 99 deletions(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index e2ce463..c9e7ec6 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -63,8 +63,8 @@ AFS -. 1a) Send sports activity event .-> AFG AFG -- 2a) Invoke get latest sports activities ---> AFS AFS -- 2b) Return latest sports activities --> AFG -AFG -- 1b) Update specific \n sports activity --> DIF -AFG -- 2c) Update the \n activities snapshot ---> DIF +AFG -- 1b) Update specific
sports activity --> DIF +AFG -- 2c) Update the
activities snapshot ---> DIF %% Styling classDef green fill:#B2DFDB,stroke:#00897B,stroke-width:2px; @@ -174,7 +174,7 @@ public final class Runner { ``` ### Polling source handler class and configuration -Create a class called `SportsActivityFeedSnapshotPollingSourceHandlerImpl` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend sports activity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: +Create a class called `SportsActivityFeedPollingSourceHandler` and have it implement the `PollingSourceHandler` interface. We will use this to periodically poll and request the activities snapshot from the pretend sports activity feed server. The `PollingSourceHandler` interface will require us to implement the following methods: - `poll` - this method is periodically called by the Gateway framework based on configuration. - `pause` - called when the Gateway adapter enters the paused state. - `resume` - is called when the Gateway adapter can resume. @@ -182,98 +182,100 @@ Create a class called `SportsActivityFeedSnapshotPollingSourceHandlerImpl` and h In your `poll` method, we will call the pretend sports activity feed server's `getSportsLatestActivities()` using the `SportsActivityFeedClient` reference passed into the constructor. Below is the complete code for the polling source handler: ```java -public final class SportsActivityFeedSnapshotPollingSourceHandlerImpl - implements PollingSourceHandler { - - static final String DEFAULT_POLLING_TOPIC_PATH = - "default/sports/activity/feed/snapshot"; - - private static final Logger LOG = - LoggerFactory.getLogger( - SportsActivityFeedSnapshotPollingSourceHandlerImpl.class); - - private final SportsActivityFeedClient sportsActivityFeedClient; - private final Publisher publisher; - private final StateHandler stateHandler; - private final ObjectMapper objectMapper; - private final String topicPath; - - public SportsActivityFeedSnapshotPollingSourceHandlerImpl( - SportsActivityFeedClient sportsActivityFeedClient, - ServiceDefinition serviceDefinition, - Publisher publisher, - StateHandler stateHandler, - ObjectMapper objectMapper) { - - this.sportsActivityFeedClient = - requireNonNull(sportsActivityFeedClient, - "sportActivityFeedClient"); - - this.publisher = requireNonNull(publisher, "publisher"); - this.stateHandler = requireNonNull(stateHandler, "stateHandler"); - requireNonNull(serviceDefinition, "serviceDefinition"); - this.objectMapper = requireNonNull(objectMapper, "objectMapper"); - - topicPath = serviceDefinition.getParameters() - .getOrDefault("topicPath", DEFAULT_POLLING_TOPIC_PATH) - .toString(); - } - - @Override - public CompletableFuture poll() { - final CompletableFuture pollCf = new CompletableFuture<>(); - - if (!stateHandler.getState().equals(ServiceState.ACTIVE)) { - pollCf.complete(null); - - return pollCf; - } - - final Collection activities = - sportsActivityFeedClient.getLatestSportsActivities(); - - if (activities.isEmpty()) { - pollCf.complete(null); - - return pollCf; - } - - try { - final String value = objectMapper.writeValueAsString(activities); - - publisher.publish(topicPath, value) - .whenComplete((o, throwable) -> { - if (throwable != null) { - pollCf.completeExceptionally(throwable); - } - else { - pollCf.complete(null); - } - }); - } - catch (JsonProcessingException | - PayloadConversionException e) { - - LOG.error("Failed to convert sports activity to JSON", e); - pollCf.completeExceptionally(e); - } - - return pollCf; - } - - @Override - public CompletableFuture pause(PauseReason reason) { - LOG.info("Paused sports activity feed polling handler"); - - return CompletableFuture.completedFuture(null); - } - - @Override - public CompletableFuture resume(ResumeReason reason) { - LOG.info("Resumed sports activity feed polling handler"); - - return CompletableFuture.completedFuture(null); - } +public final class SportsActivityFeedPollingSourceHandler + implements PollingSourceHandler { + + static final String DEFAULT_POLLING_TOPIC_PATH = + "default/sports/activity/feed/snapshot"; + + private static final Logger LOG = + LoggerFactory.getLogger( + SportsActivityFeedPollingSourceHandler.class); + + private final SportsActivityFeedClient sportsActivityFeedClient; + private final Publisher publisher; + private final StateHandler stateHandler; + private final ObjectMapper objectMapper; + private final String topicPath; + + public SportsActivityFeedPollingSourceHandler( + SportsActivityFeedClient sportsActivityFeedClient, + ServiceDefinition serviceDefinition, + Publisher publisher, + StateHandler stateHandler, + ObjectMapper objectMapper) { + + this.sportsActivityFeedClient = + requireNonNull(sportsActivityFeedClient, + "sportActivityFeedClient"); + + this.publisher = requireNonNull(publisher, "publisher"); + this.stateHandler = requireNonNull(stateHandler, "stateHandler"); + requireNonNull(serviceDefinition, "serviceDefinition"); + this.objectMapper = requireNonNull(objectMapper, "objectMapper"); + + topicPath = serviceDefinition.getParameters() + .getOrDefault("topicPath", DEFAULT_POLLING_TOPIC_PATH) + .toString(); + } + + @Override + public CompletableFuture poll() { + final CompletableFuture pollCf = new CompletableFuture<>(); + + if (!stateHandler.getState().equals(ServiceState.ACTIVE)) { + pollCf.complete(null); + + return pollCf; + } + + final Collection activities = + sportsActivityFeedClient.getLatestSportsActivities(); + + if (activities.isEmpty()) { + pollCf.complete(null); + + return pollCf; + } + + try { + final String value = objectMapper.writeValueAsString(activities); + + publisher.publish(topicPath, value) + .whenComplete((o, throwable) -> { + if (throwable != null) { + pollCf.completeExceptionally(throwable); + } + else { + pollCf.complete(null); + } + }); + } + catch (JsonProcessingException | + PayloadConversionException e) { + + LOG.error( + "Failed to convert sports activity to configured type", e); + + pollCf.completeExceptionally(e); + } + + return pollCf; + } + + @Override + public CompletableFuture pause(PauseReason reason) { + LOG.info("Paused sports activity feed polling handler"); + + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture resume(ResumeReason reason) { + LOG.info("Resumed sports activity feed polling handler"); + + return CompletableFuture.completedFuture(null); + } } ``` @@ -664,7 +666,7 @@ You have now built a fully functioning Gateway adapter that handles both streami - Controlling the Gateway adapter through the Diffusion console (pause, resume and stop). Several prebuilt Gateway adapters are ready and require configuration to connect to various datasources. The existing DiffusionData Gateway adapters are: -- Kafka Adapter -- CDC Adapter -- REST Adapter -- Redis Adapter +- Kafka Adapter. +- CDC Adapter. +- REST Adapter. +- Redis Adapter. From 6518891ee7fd30481268d698bfcc20c6e01ca56e Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Mon, 25 Nov 2024 17:49:04 +0000 Subject: [PATCH 32/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index c9e7ec6..421d0d1 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -1,4 +1,4 @@ -# Activity feed Gateway adapter example +# Sports activity feed Gateway adapter example This project demonstrates the use of the Diffusion Gateway Framework. The Gateway Framework provides an easy and consistent way to develop applications From 81803e840a150e215701baab166c09131e623c24 Mon Sep 17 00:00:00 2001 From: Jonathan Hall Date: Fri, 6 Dec 2024 14:43:47 +0000 Subject: [PATCH 33/33] DOC-428: sports activity feed streaming and polling example. --- sports-activity-feed-adapter/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sports-activity-feed-adapter/README.md b/sports-activity-feed-adapter/README.md index 421d0d1..94a1025 100644 --- a/sports-activity-feed-adapter/README.md +++ b/sports-activity-feed-adapter/README.md @@ -665,6 +665,22 @@ You have now built a fully functioning Gateway adapter that handles both streami - Prometheus metrics. - Controlling the Gateway adapter through the Diffusion console (pause, resume and stop). +### Useful links + +Diffusion self-host/on-prem installer: https://www.diffusiondata.com/diffusion-on-premise/ + +Diffusion Cloud SaaS: https://www.diffusiondata.com/diffusion-cloud/ + +Diffusion Docker image: https://hub.docker.com/r/pushtechnology/docker-diffusion + +Gateway framework user guide: https://docs.diffusiondata.com/gateway-framework/latest/user-guide/contents/index.html + +Developing a Gateway adapter: https://docs.diffusiondata.com/gateway-framework/latest/user-guide/contents/writing-a-gateway-application/overview.html + +Gateway configuration details: https://docs.diffusiondata.com/gateway-framework/latest/user-guide/contents/configuration/configuration-file-details/configuration-file-details.html + +### Prebuilt Gateway adapters + Several prebuilt Gateway adapters are ready and require configuration to connect to various datasources. The existing DiffusionData Gateway adapters are: - Kafka Adapter. - CDC Adapter.