├── .github
└── FUNDING.yml
├── .gitignore
├── .mvn
└── wrapper
│ └── maven-wrapper.properties
├── README.md
├── build-docker-images.sh
├── consumer-service
├── pom.xml
└── src
│ ├── main
│ ├── java
│ │ └── com
│ │ │ └── ivanfranchin
│ │ │ └── consumerservice
│ │ │ ├── ConsumerServiceApplication.java
│ │ │ ├── alert
│ │ │ ├── AlertEventConsumer.java
│ │ │ └── event
│ │ │ │ ├── AlertEvent.java
│ │ │ │ ├── EarthquakeAlert.java
│ │ │ │ └── WeatherAlert.java
│ │ │ ├── config
│ │ │ └── MessageRoutingConfig.java
│ │ │ ├── news
│ │ │ ├── NewsEventConsumer.java
│ │ │ └── event
│ │ │ │ ├── CNNNewsCreated.java
│ │ │ │ ├── DWNewsCreated.java
│ │ │ │ ├── NewsEvent.java
│ │ │ │ └── RAINewsCreated.java
│ │ │ └── properties
│ │ │ └── AppConfigurationProperties.java
│ └── resources
│ │ ├── application.properties
│ │ └── banner.txt
│ └── test
│ └── java
│ └── com
│ └── ivanfranchin
│ └── consumerservice
│ ├── alert
│ └── AlertEventConsumerTest.java
│ ├── config
│ └── MessageRoutingConfigTest.java
│ └── news
│ └── NewsEventConsumerTest.java
├── docker-compose.yml
├── documentation
├── project-diagram.excalidraw
└── project-diagram.jpeg
├── mvnw
├── mvnw.cmd
├── pom.xml
├── producer-service
├── pom.xml
└── src
│ ├── main
│ ├── java
│ │ └── com
│ │ │ └── ivanfranchin
│ │ │ └── producerservice
│ │ │ ├── ProducerServiceApplication.java
│ │ │ ├── alert
│ │ │ ├── AlertController.java
│ │ │ ├── AlertEventEmitter.java
│ │ │ ├── dto
│ │ │ │ ├── CreateEarthquakeAlertRequest.java
│ │ │ │ └── CreateWeatherAlertRequest.java
│ │ │ └── event
│ │ │ │ ├── AlertEvent.java
│ │ │ │ ├── EarthquakeAlert.java
│ │ │ │ └── WeatherAlert.java
│ │ │ ├── config
│ │ │ ├── ErrorAttributesConfig.java
│ │ │ └── NativeRuntimeHintsRegistrar.java
│ │ │ └── news
│ │ │ ├── NewsController.java
│ │ │ ├── NewsEventEmitter.java
│ │ │ ├── dto
│ │ │ ├── CreateCNNNewsRequest.java
│ │ │ ├── CreateDWNewsRequest.java
│ │ │ └── CreateRAINewsRequest.java
│ │ │ └── event
│ │ │ ├── CNNNewsCreated.java
│ │ │ ├── DWNewsCreated.java
│ │ │ ├── NewsEvent.java
│ │ │ └── RAINewsCreated.java
│ └── resources
│ │ ├── application.properties
│ │ └── banner.txt
│ └── test
│ └── java
│ └── com
│ └── ivanfranchin
│ └── producerservice
│ ├── alert
│ ├── AlertControllerTest.java
│ └── AlertEventEmitterTest.java
│ └── news
│ ├── NewsControllerTest.java
│ └── NewsEventEmitterTest.java
└── remove-docker-images.sh
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: ivangfr
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | target/
2 | !.mvn/wrapper/maven-wrapper.jar
3 | !**/src/main/**/target/
4 | !**/src/test/**/target/
5 |
6 | ### STS ###
7 | .apt_generated
8 | .classpath
9 | .factorypath
10 | .project
11 | .settings
12 | .springBeans
13 | .sts4-cache
14 |
15 | ### IntelliJ IDEA ###
16 | .idea
17 | *.iws
18 | *.iml
19 | *.ipr
20 |
21 | ### NetBeans ###
22 | /nbproject/private/
23 | /nbbuild/
24 | /dist/
25 | /nbdist/
26 | /.nb-gradle/
27 | build/
28 | !**/src/main/**/build/
29 | !**/src/test/**/build/
30 |
31 | ### VS Code ###
32 | .vscode/
33 |
34 | ### MAC OS ###
35 | *.DS_Store
36 |
--------------------------------------------------------------------------------
/.mvn/wrapper/maven-wrapper.properties:
--------------------------------------------------------------------------------
1 | # Licensed to the Apache Software Foundation (ASF) under one
2 | # or more contributor license agreements. See the NOTICE file
3 | # distributed with this work for additional information
4 | # regarding copyright ownership. The ASF licenses this file
5 | # to you under the Apache License, Version 2.0 (the
6 | # "License"); you may not use this file except in compliance
7 | # with the License. You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing,
12 | # software distributed under the License is distributed on an
13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14 | # KIND, either express or implied. See the License for the
15 | # specific language governing permissions and limitations
16 | # under the License.
17 | wrapperVersion=3.3.2
18 | distributionType=only-script
19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # spring-cloud-stream-event-routing-cloudevents
2 |
3 | The goal of this project is to play with [`Spring Cloud Stream Event Routing`](https://docs.spring.io/spring-cloud-stream/docs/current/reference/html/spring-cloud-stream.html#_event_routing) and [`CloudEvents`](https://cloudevents.io/). For it, we will implement a producer and consumer of `news` & `alert` events.
4 |
5 | ## Proof-of-Concepts & Articles
6 |
7 | On [ivangfr.github.io](https://ivangfr.github.io), I have compiled my Proof-of-Concepts (PoCs) and articles. You can easily search for the technology you are interested in by using the filter. Who knows, perhaps I have already implemented a PoC or written an article about what you are looking for.
8 |
9 | ## Additional Readings
10 |
11 | - \[**Medium**\] [**How to Route CloudEvents messages with Spring Cloud Stream**](https://medium.com/@ivangfr/how-to-route-cloudevents-messages-with-spring-cloud-stream-3cf7a5ab4e17)
12 | - \[**Medium**\] [**Implementing a Kafka Producer and Consumer using Spring Cloud Stream**](https://medium.com/@ivangfr/implementing-a-kafka-producer-and-consumer-using-spring-cloud-stream-d4b9a6a9eab1)
13 | - \[**Medium**\] [**Implementing Unit Tests for a Kafka Producer and Consumer that uses Spring Cloud Stream**](https://medium.com/@ivangfr/implementing-unit-tests-for-a-kafka-producer-and-consumer-that-uses-spring-cloud-stream-f7a98a89fcf2)
14 | - \[**Medium**\] [**Implementing End-to-End testing for a Kafka Producer and Consumer that uses Spring Cloud Stream**](https://medium.com/@ivangfr/implementing-end-to-end-testing-for-a-kafka-producer-and-consumer-that-uses-spring-cloud-stream-fbf5e666899e)
15 | - \[**Medium**\] [**Configuring Distributed Tracing with Zipkin in a Kafka Producer and Consumer that uses Spring Cloud Stream**](https://medium.com/@ivangfr/configuring-distributed-tracing-with-zipkin-in-a-kafka-producer-and-consumer-that-uses-spring-cloud-9f1e55468b9e)
16 | - \[**Medium**\] [**Using Cloudevents in a Kafka Producer and Consumer that uses Spring Cloud Stream**](https://medium.com/@ivangfr/using-cloudevents-in-a-kafka-producer-and-consumer-that-uses-spring-cloud-stream-9c51670b5566)
17 |
18 | ## Project Diagram
19 |
20 | 
21 |
22 | ## Applications
23 |
24 | - ### producer-service
25 |
26 | [`Spring Boot`](https://docs.spring.io/spring-boot/index.html) application that exposes a REST API to submit `news` & `alert` events.
27 |
28 | Endpoints
29 | ```
30 | POST /api/news/cnn {"title":"..."}
31 | POST /api/news/dw {"titel":"..."}
32 | POST /api/news/rai {"titolo":"..."}
33 | POST /api/alerts/earthquake {"richterScale":"...", "epicenterLat":"...", "epicenterLon":"..."}
34 | POST /api/alerts/weather {"message":"..."}
35 | ```
36 |
37 | - ### consumer-service
38 |
39 | `Spring Boot` application that consumes the `news` & `alert` events published by `producer-service`.
40 |
41 | ## Prerequisites
42 |
43 | - [`Java 21+`](https://www.oracle.com/java/technologies/downloads/#java21)
44 | - Some containerization tool [`Docker`](https://www.docker.com), [`Podman`](https://podman.io), etc.
45 |
46 | ## Start Environment
47 |
48 | - Open a terminal and inside the `spring-cloud-stream-event-routing-cloudevents` root folder run:
49 | ```
50 | docker compose up -d
51 | ```
52 |
53 | - Wait for Docker containers to be up and running. To check it, run:
54 | ```
55 | docker ps -a
56 | ```
57 |
58 | ## Running Applications with Maven
59 |
60 | - **producer-service**
61 |
62 | - In a terminal, make sure you are in the `spring-cloud-stream-event-routing-cloudevents` root folder;
63 | - Run the command below to start the application:
64 | ```
65 | ./mvnw clean spring-boot:run --projects producer-service
66 | ```
67 |
68 | - **consumer-service**
69 |
70 | - Open a new terminal and navigate to the `spring-cloud-stream-event-routing-cloudevents` root folder
71 | - Run the following command to start the application:
72 | ```
73 | ./mvnw clean spring-boot:run --projects consumer-service
74 | ```
75 |
76 | ## Running Applications as Docker containers
77 |
78 | - ### Build Docker Images
79 |
80 | - In a terminal, make sure you are inside the `spring-cloud-stream-event-routing-cloudevents` root folder;
81 | - Run the following script to build the Docker images:
82 | - JVM
83 | ```
84 | ./build-docker-images.sh
85 | ```
86 | - Native
87 | ```
88 | ./build-docker-images.sh native
89 | ```
90 |
91 | - ### Environment Variables
92 |
93 | - **producer-service**
94 |
95 | | Environment Variable | Description |
96 | |----------------------|-------------------------------------------------------------------------|
97 | | `KAFKA_HOST` | Specify host of the `Kafka` message broker to use (default `localhost`) |
98 | | `KAFKA_PORT` | Specify port of the `Kafka` message broker to use (default `29092`) |
99 |
100 | - **consumer-service**
101 |
102 | | Environment Variable | Description |
103 | |----------------------|-------------------------------------------------------------------------|
104 | | `KAFKA_HOST` | Specify host of the `Kafka` message broker to use (default `localhost`) |
105 | | `KAFKA_PORT` | Specify port of the `Kafka` message broker to use (default `29092`) |
106 |
107 | - ### Run Docker Containers
108 |
109 | - **producer-service**
110 |
111 | Run the following command in a terminal:
112 | ```
113 | docker run --rm --name producer-service -p 9080:9080 \
114 | -e KAFKA_HOST=kafka -e KAFKA_PORT=9092 \
115 | --network=spring-cloud-stream-event-routing-cloudevents_default \
116 | ivanfranchin/producer-service:1.0.0
117 | ```
118 |
119 | - **consumer-service**
120 |
121 | Open a new terminal and run the following command:
122 | ```
123 | docker run --rm --name consumer-service -p 9081:9081 \
124 | -e KAFKA_HOST=kafka -e KAFKA_PORT=9092 \
125 | --network=spring-cloud-stream-event-routing-cloudevents_default \
126 | ivanfranchin/consumer-service:1.0.0
127 | ```
128 |
129 | ## Playing around
130 |
131 | In a terminal, submit the following POST requests to `producer-service` and check its logs and `consumer-service` logs
132 |
133 | > **Note**: [HTTPie](https://httpie.org/) is being used in the calls bellow
134 |
135 | - **news**
136 | ```
137 | http :9080/api/news/cnn title="NYC subway strike"
138 | http :9080/api/news/dw titel="Berliner Untergrundstreik"
139 | http :9080/api/news/rai titolo="Sciopero della metropolitana di Roma"
140 | ```
141 |
142 | - **alerts**
143 | ```
144 | http :9080/api/alerts/earthquake richterScale=5.5 epicenterLat=37.7840781 epicenterLon=-25.7977037
145 | http :9080/api/alerts/weather message="Thunderstorm in Berlin"
146 | ```
147 |
148 | ## Useful links
149 |
150 | - **Kafdrop**
151 |
152 | `Kafdrop` can be accessed at http://localhost:9000
153 |
154 | ## Shutdown
155 |
156 | - To stop applications, go to the terminals where they are running and press `Ctrl+C`
157 | - To stop and remove docker compose containers, network and volumes, go to a terminal and, inside the `spring-cloud-stream-event-routing-cloudevents` root folder, run the following command:
158 | ```
159 | docker compose down -v
160 | ```
161 |
162 | ## Running Test Cases
163 |
164 | In a terminal, make sure you are inside the `spring-cloud-stream-event-routing-cloudevents` root folder:
165 |
166 | - **producer-service**
167 | ```
168 | ./mvnw clean test --projects producer-service
169 | ```
170 |
171 | - **consumer-service**
172 | ```
173 | ./mvnw clean test --projects consumer-service
174 | ```
175 |
176 | ## Cleanup
177 |
178 | To remove the Docker images created by this project, go to a terminal and inside the `spring-cloud-stream-event-routing-cloudevents` root folder, run the following script:
179 | ```
180 | ./remove-docker-images.sh
181 | ```
182 |
--------------------------------------------------------------------------------
/build-docker-images.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | DOCKER_IMAGE_PREFIX="ivanfranchin"
4 | APP_VERSION="1.0.0"
5 |
6 | PRODUCER_APP_NAME="producer-service"
7 | CONSUMER_APP_NAME="consumer-service"
8 |
9 | PRODUCER_DOCKER_IMAGE_NAME="${DOCKER_IMAGE_PREFIX}/${PRODUCER_APP_NAME}:${APP_VERSION}"
10 | CONSUMER_DOCKER_IMAGE_NAME="${DOCKER_IMAGE_PREFIX}/${CONSUMER_APP_NAME}:${APP_VERSION}"
11 |
12 | SKIP_TESTS="true"
13 |
14 | if [ "$1" = "native" ];
15 | then
16 | ./mvnw -Pnative clean spring-boot:build-image --projects "$PRODUCER_APP_NAME" -DskipTests="$SKIP_TESTS" -Dspring-boot.build-image.imageName="$PRODUCER_DOCKER_IMAGE_NAME"
17 | ./mvnw -Pnative clean spring-boot:build-image --projects "$CONSUMER_APP_NAME" -DskipTests="$SKIP_TESTS" -Dspring-boot.build-image.imageName="$CONSUMER_DOCKER_IMAGE_NAME"
18 | else
19 | ./mvnw clean spring-boot:build-image --projects "$PRODUCER_APP_NAME" -DskipTests="$SKIP_TESTS" -Dspring-boot.build-image.imageName="$PRODUCER_DOCKER_IMAGE_NAME"
20 | ./mvnw clean spring-boot:build-image --projects "$CONSUMER_APP_NAME" -DskipTests="$SKIP_TESTS" -Dspring-boot.build-image.imageName="$CONSUMER_DOCKER_IMAGE_NAME"
21 | fi
22 |
--------------------------------------------------------------------------------
/consumer-service/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 | 4.0.0
5 |
6 | com.ivanfranchin
7 | spring-cloud-stream-event-routing-cloudevents
8 | 1.0.0
9 | ../pom.xml
10 |
11 | consumer-service
12 | consumer-service
13 | Demo project for Spring Boot
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | org.springframework.boot
30 | spring-boot-starter-webflux
31 |
32 |
33 | org.springframework.cloud
34 | spring-cloud-stream
35 |
36 |
37 | org.springframework.cloud
38 | spring-cloud-stream-binder-kafka
39 |
40 |
41 | org.springframework.kafka
42 | spring-kafka
43 |
44 |
45 |
46 | org.springframework.boot
47 | spring-boot-starter-test
48 | test
49 |
50 |
51 | io.projectreactor
52 | reactor-test
53 | test
54 |
55 |
56 | org.springframework.cloud
57 | spring-cloud-stream-test-binder
58 | test
59 |
60 |
61 | org.springframework.kafka
62 | spring-kafka-test
63 | test
64 |
65 |
66 |
67 |
68 |
69 |
70 | org.graalvm.buildtools
71 | native-maven-plugin
72 |
73 |
74 | org.springframework.boot
75 | spring-boot-maven-plugin
76 |
77 |
78 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/ConsumerServiceApplication.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice;
2 |
3 | import org.springframework.boot.SpringApplication;
4 | import org.springframework.boot.autoconfigure.SpringBootApplication;
5 |
6 | @SpringBootApplication
7 | public class ConsumerServiceApplication {
8 |
9 | public static void main(String[] args) {
10 | SpringApplication.run(ConsumerServiceApplication.class, args);
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/alert/AlertEventConsumer.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.alert;
2 |
3 | import com.ivanfranchin.consumerservice.alert.event.EarthquakeAlert;
4 | import com.ivanfranchin.consumerservice.alert.event.WeatherAlert;
5 | import org.slf4j.Logger;
6 | import org.slf4j.LoggerFactory;
7 | import org.springframework.context.annotation.Bean;
8 | import org.springframework.messaging.Message;
9 | import org.springframework.stereotype.Component;
10 |
11 | import java.util.function.Consumer;
12 |
13 | @Component
14 | public class AlertEventConsumer {
15 |
16 | private static final Logger log = LoggerFactory.getLogger(AlertEventConsumer.class);
17 |
18 | @Bean
19 | Consumer> earthquakeAlert() {
20 | return message -> log.info(
21 | LOG_TEMPLATE, "Received Earthquake alert!", message.getHeaders(), message.getPayload());
22 | }
23 |
24 | @Bean
25 | Consumer> weatherAlert() {
26 | return message -> log.info(
27 | LOG_TEMPLATE, "Received Weather alert!", message.getHeaders(), message.getPayload());
28 | }
29 |
30 | private static final String LOG_TEMPLATE = "{}\n---\nHEADERS: {}\n...\nPAYLOAD: {}\n---";
31 | }
32 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/alert/event/AlertEvent.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.alert.event;
2 |
3 | public interface AlertEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/alert/event/EarthquakeAlert.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.alert.event;
2 |
3 | public record EarthquakeAlert(String id, Double richterScale, Double epicenterLat,
4 | Double epicenterLon) implements AlertEvent {
5 | }
6 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/alert/event/WeatherAlert.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.alert.event;
2 |
3 | public record WeatherAlert(String id, String message) implements AlertEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/config/MessageRoutingConfig.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.config;
2 |
3 | import com.ivanfranchin.consumerservice.properties.AppConfigurationProperties;
4 | import org.slf4j.Logger;
5 | import org.slf4j.LoggerFactory;
6 | import org.springframework.cloud.function.cloudevent.CloudEventMessageUtils;
7 | import org.springframework.cloud.function.context.MessageRoutingCallback;
8 | import org.springframework.context.annotation.Bean;
9 | import org.springframework.context.annotation.Configuration;
10 | import org.springframework.messaging.Message;
11 |
12 | import java.util.function.Consumer;
13 |
14 | @Configuration
15 | public class MessageRoutingConfig {
16 |
17 | private static final Logger log = LoggerFactory.getLogger(MessageRoutingConfig.class);
18 |
19 | private final AppConfigurationProperties appConfigurationProperties;
20 |
21 | public MessageRoutingConfig(AppConfigurationProperties appConfigurationProperties) {
22 | this.appConfigurationProperties = appConfigurationProperties;
23 | }
24 |
25 | @Bean
26 | MessageRoutingCallback messageRoutingCallback() {
27 | return new MessageRoutingCallback() {
28 | @Override
29 | public String routingResult(Message> message) {
30 | return appConfigurationProperties.getRoutingMap()
31 | .getOrDefault(CloudEventMessageUtils.getType(message), "unknownEvent");
32 | }
33 | };
34 | }
35 |
36 | @Bean
37 | Consumer> unknownEvent() {
38 | return message -> log.warn(LOG_TEMPLATE, "Received unknown event!", message.getHeaders(), message.getPayload());
39 | }
40 |
41 | private static final String LOG_TEMPLATE = "{}\n---\nHEADERS: {}\n...\nPAYLOAD: {}\n---";
42 | }
43 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/news/NewsEventConsumer.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.news;
2 |
3 | import com.ivanfranchin.consumerservice.news.event.CNNNewsCreated;
4 | import com.ivanfranchin.consumerservice.news.event.DWNewsCreated;
5 | import com.ivanfranchin.consumerservice.news.event.RAINewsCreated;
6 | import org.slf4j.Logger;
7 | import org.slf4j.LoggerFactory;
8 | import org.springframework.context.annotation.Bean;
9 | import org.springframework.messaging.Message;
10 | import org.springframework.stereotype.Component;
11 |
12 | import java.util.function.Consumer;
13 |
14 | @Component
15 | public class NewsEventConsumer {
16 |
17 | private static final Logger log = LoggerFactory.getLogger(NewsEventConsumer.class);
18 |
19 | @Bean
20 | Consumer> cnnNewsCreated() {
21 | return message -> log.info(
22 | LOG_TEMPLATE, "Received news created message from CNN!", message.getHeaders(), message.getPayload());
23 | }
24 |
25 | @Bean
26 | Consumer> dwNewsCreated() {
27 | return message -> log.info(
28 | LOG_TEMPLATE, "Erhaltene Nachrichten erstellte Nachricht von DW!", message.getHeaders(), message.getPayload());
29 | }
30 |
31 | @Bean
32 | Consumer> raiNewsCreated() {
33 | return message -> log.info(
34 | LOG_TEMPLATE, "Ricevuta notizia creata messaggio da RAI!", message.getHeaders(), message.getPayload());
35 | }
36 |
37 | private static final String LOG_TEMPLATE = "{}\n---\nHEADERS: {}\n...\nPAYLOAD: {}\n---";
38 | }
39 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/news/event/CNNNewsCreated.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.news.event;
2 |
3 | public record CNNNewsCreated(String id, String title) implements NewsEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/news/event/DWNewsCreated.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.news.event;
2 |
3 | public record DWNewsCreated(String id, String titel) implements NewsEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/news/event/NewsEvent.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.news.event;
2 |
3 | public interface NewsEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/news/event/RAINewsCreated.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.news.event;
2 |
3 | public record RAINewsCreated(String id, String titolo) implements NewsEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/consumer-service/src/main/java/com/ivanfranchin/consumerservice/properties/AppConfigurationProperties.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.properties;
2 |
3 | import org.springframework.boot.context.properties.ConfigurationProperties;
4 | import org.springframework.context.annotation.Configuration;
5 |
6 | import java.util.Map;
7 |
8 | @Configuration
9 | @ConfigurationProperties(prefix = "app")
10 | public class AppConfigurationProperties {
11 |
12 | private Map routingMap;
13 |
14 | public Map getRoutingMap() {
15 | return routingMap;
16 | }
17 |
18 | public void setRoutingMap(Map routingMap) {
19 | this.routingMap = routingMap;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/consumer-service/src/main/resources/application.properties:
--------------------------------------------------------------------------------
1 | server.port=9081
2 |
3 | spring.application.name=consumer-service
4 |
5 | spring.cloud.stream.kafka.binder.brokers=${KAFKA_HOST:localhost}:${KAFKA_PORT:29092}
6 | spring.cloud.stream.kafka.binder.min-partition-count=3
7 |
8 | spring.cloud.function.definition=functionRouter
9 | spring.cloud.stream.bindings.functionRouter-in-0.destination=news.events,alert.events
10 | spring.cloud.stream.bindings.functionRouter-in-0.content-type=application/json
11 | spring.cloud.stream.bindings.functionRouter-in-0.group=consumerServiceGroup
12 | spring.cloud.stream.bindings.functionRouter-in-0.consumer.start-offset=latest
13 | spring.cloud.stream.bindings.functionRouter-in-0.consumer.concurrency=2
14 |
15 | logging.level.org.apache.kafka.clients.consumer.internals.ConsumerCoordinator=WARN
16 | logging.level.org.apache.kafka.clients.Metadata=WARN
17 |
18 | app.routing-map.com.ivanfranchin.producerservice.alert.event.EarthquakeAlert=earthquakeAlert
19 | app.routing-map.com.ivanfranchin.producerservice.alert.event.WeatherAlert=weatherAlert
20 | app.routing-map.com.ivanfranchin.producerservice.news.event.CNNNewsCreated=cnnNewsCreated
21 | app.routing-map.com.ivanfranchin.producerservice.news.event.DWNewsCreated=dwNewsCreated
22 | app.routing-map.com.ivanfranchin.producerservice.news.event.RAINewsCreated=raiNewsCreated
23 |
--------------------------------------------------------------------------------
/consumer-service/src/main/resources/banner.txt:
--------------------------------------------------------------------------------
1 | _
2 | ___ ___ _ __ ___ _ _ _ __ ___ ___ _ __ ___ ___ _ ____ _(_) ___ ___
3 | / __/ _ \| '_ \/ __| | | | '_ ` _ \ / _ \ '__|____/ __|/ _ \ '__\ \ / / |/ __/ _ \
4 | | (_| (_) | | | \__ \ |_| | | | | | | __/ | |_____\__ \ __/ | \ V /| | (_| __/
5 | \___\___/|_| |_|___/\__,_|_| |_| |_|\___|_| |___/\___|_| \_/ |_|\___\___|
6 | :: Spring Boot :: ${spring-boot.formatted-version}
7 |
--------------------------------------------------------------------------------
/consumer-service/src/test/java/com/ivanfranchin/consumerservice/alert/AlertEventConsumerTest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.alert;
2 |
3 | import com.ivanfranchin.consumerservice.ConsumerServiceApplication;
4 | import com.ivanfranchin.consumerservice.alert.event.AlertEvent;
5 | import com.ivanfranchin.consumerservice.alert.event.EarthquakeAlert;
6 | import com.ivanfranchin.consumerservice.alert.event.WeatherAlert;
7 | import org.junit.jupiter.api.Test;
8 | import org.junit.jupiter.api.extension.ExtendWith;
9 | import org.springframework.boot.WebApplicationType;
10 | import org.springframework.boot.builder.SpringApplicationBuilder;
11 | import org.springframework.boot.test.system.CapturedOutput;
12 | import org.springframework.boot.test.system.OutputCaptureExtension;
13 | import org.springframework.cloud.function.cloudevent.CloudEventMessageBuilder;
14 | import org.springframework.cloud.stream.binder.test.InputDestination;
15 | import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration;
16 | import org.springframework.context.ConfigurableApplicationContext;
17 | import org.springframework.messaging.Message;
18 |
19 | import static org.assertj.core.api.Assertions.assertThat;
20 |
21 | @ExtendWith(OutputCaptureExtension.class)
22 | class AlertEventConsumerTest {
23 |
24 | @Test
25 | void testEarthquakeAlert(CapturedOutput output) {
26 | try (ConfigurableApplicationContext context = new SpringApplicationBuilder(
27 | TestChannelBinderConfiguration.getCompleteConfiguration(
28 | ConsumerServiceApplication.class))
29 | .web(WebApplicationType.NONE)
30 | .run("--spring.jmx.enabled=false")) {
31 |
32 | AlertEvent alertEvent = new EarthquakeAlert("id", 2.1, 1.0, -1.0);
33 | Message alertEventMessage = CloudEventMessageBuilder
34 | .withData(alertEvent)
35 | .setType("com.ivanfranchin.producerservice.alert.event.EarthquakeAlert")
36 | .setHeader(PARTITION_KEY, "id")
37 | .build();
38 |
39 | InputDestination inputDestination = context.getBean(InputDestination.class);
40 | inputDestination.send(alertEventMessage, DESTINATION_NAME);
41 |
42 | assertThat(output).contains("Received Earthquake alert!");
43 | assertThat(output).contains("PAYLOAD: EarthquakeAlert[id=id, richterScale=2.1, epicenterLat=1.0, epicenterLon=-1.0]");
44 | }
45 | }
46 |
47 | @Test
48 | void testWeatherAlert(CapturedOutput output) {
49 | try (ConfigurableApplicationContext context = new SpringApplicationBuilder(
50 | TestChannelBinderConfiguration.getCompleteConfiguration(
51 | ConsumerServiceApplication.class))
52 | .web(WebApplicationType.NONE)
53 | .run("--spring.jmx.enabled=false")) {
54 |
55 | AlertEvent alertEvent = new WeatherAlert("id", "message");
56 | Message alertEventMessage = CloudEventMessageBuilder
57 | .withData(alertEvent)
58 | .setType("com.ivanfranchin.producerservice.alert.event.WeatherAlert")
59 | .setHeader(PARTITION_KEY, "id")
60 | .build();
61 |
62 | InputDestination inputDestination = context.getBean(InputDestination.class);
63 | inputDestination.send(alertEventMessage, DESTINATION_NAME);
64 |
65 | assertThat(output).contains("Received Weather alert!");
66 | assertThat(output).contains("PAYLOAD: WeatherAlert[id=id, message=message]");
67 | }
68 | }
69 |
70 | private static final String DESTINATION_NAME = "alert.events";
71 | private static final String PARTITION_KEY = "partitionKey";
72 | }
--------------------------------------------------------------------------------
/consumer-service/src/test/java/com/ivanfranchin/consumerservice/config/MessageRoutingConfigTest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.config;
2 |
3 | import com.ivanfranchin.consumerservice.ConsumerServiceApplication;
4 | import org.junit.jupiter.api.Test;
5 | import org.junit.jupiter.api.extension.ExtendWith;
6 | import org.springframework.boot.WebApplicationType;
7 | import org.springframework.boot.builder.SpringApplicationBuilder;
8 | import org.springframework.boot.test.system.CapturedOutput;
9 | import org.springframework.boot.test.system.OutputCaptureExtension;
10 | import org.springframework.cloud.function.cloudevent.CloudEventMessageBuilder;
11 | import org.springframework.cloud.stream.binder.test.InputDestination;
12 | import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration;
13 | import org.springframework.context.ConfigurableApplicationContext;
14 | import org.springframework.messaging.Message;
15 |
16 | import static org.assertj.core.api.Assertions.assertThat;
17 |
18 | @ExtendWith(OutputCaptureExtension.class)
19 | class MessageRoutingConfigTest {
20 |
21 | @Test
22 | void testUnknownEvent(CapturedOutput output) {
23 | try (ConfigurableApplicationContext context = new SpringApplicationBuilder(
24 | TestChannelBinderConfiguration.getCompleteConfiguration(
25 | ConsumerServiceApplication.class))
26 | .web(WebApplicationType.NONE)
27 | .run("--spring.jmx.enabled=false")) {
28 |
29 | Message alertEventMessage = CloudEventMessageBuilder
30 | .withData("Unknown Event")
31 | .setType("com.ivanfranchin.producerservice.UnknownEvent")
32 | .setHeader(PARTITION_KEY, "id")
33 | .build();
34 |
35 | InputDestination inputDestination = context.getBean(InputDestination.class);
36 | inputDestination.send(alertEventMessage, DESTINATION_NAME);
37 |
38 | assertThat(output).contains("Received unknown event!");
39 | assertThat(output).contains("Unknown Event");
40 | }
41 | }
42 |
43 | private static final String DESTINATION_NAME = "news.events";
44 | private static final String PARTITION_KEY = "partitionKey";
45 | }
--------------------------------------------------------------------------------
/consumer-service/src/test/java/com/ivanfranchin/consumerservice/news/NewsEventConsumerTest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.consumerservice.news;
2 |
3 | import com.ivanfranchin.consumerservice.news.event.CNNNewsCreated;
4 | import com.ivanfranchin.consumerservice.news.event.DWNewsCreated;
5 | import com.ivanfranchin.consumerservice.news.event.NewsEvent;
6 | import com.ivanfranchin.consumerservice.news.event.RAINewsCreated;
7 | import org.junit.jupiter.api.Test;
8 | import org.junit.jupiter.api.extension.ExtendWith;
9 | import org.springframework.beans.factory.annotation.Autowired;
10 | import org.springframework.boot.test.context.SpringBootTest;
11 | import org.springframework.boot.test.system.CapturedOutput;
12 | import org.springframework.boot.test.system.OutputCaptureExtension;
13 | import org.springframework.cloud.function.cloudevent.CloudEventMessageBuilder;
14 | import org.springframework.cloud.stream.binder.test.InputDestination;
15 | import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration;
16 | import org.springframework.context.annotation.Import;
17 | import org.springframework.messaging.Message;
18 |
19 | import static org.assertj.core.api.Assertions.assertThat;
20 |
21 | @ExtendWith(OutputCaptureExtension.class)
22 | @SpringBootTest
23 | @Import(TestChannelBinderConfiguration.class)
24 | class NewsEventConsumerTest {
25 |
26 | @Autowired
27 | private InputDestination inputDestination;
28 |
29 | @Test
30 | void testCNNNewsCreated(CapturedOutput output) {
31 | NewsEvent newsEvent = new CNNNewsCreated("id", "title");
32 | Message newsEventMessage = CloudEventMessageBuilder
33 | .withData(newsEvent)
34 | .setType("com.ivanfranchin.producerservice.news.event.CNNNewsCreated")
35 | .setHeader(PARTITION_KEY, "id")
36 | .build();
37 |
38 | inputDestination.send(newsEventMessage, DESTINATION_NAME);
39 |
40 | assertThat(output).contains("Received news created message from CNN!");
41 | assertThat(output).contains("PAYLOAD: CNNNewsCreated[id=id, title=title]");
42 | }
43 |
44 | @Test
45 | void testDWNewsCreated(CapturedOutput output) {
46 | NewsEvent newsEvent = new DWNewsCreated("id", "titel");
47 | Message newsEventMessage = CloudEventMessageBuilder
48 | .withData(newsEvent)
49 | .setType("com.ivanfranchin.producerservice.news.event.DWNewsCreated")
50 | .setHeader(PARTITION_KEY, "id")
51 | .build();
52 |
53 | inputDestination.send(newsEventMessage, DESTINATION_NAME);
54 |
55 | assertThat(output).contains("Erhaltene Nachrichten erstellte Nachricht von DW!");
56 | assertThat(output).contains("PAYLOAD: DWNewsCreated[id=id, titel=titel]");
57 | }
58 |
59 | @Test
60 | void testRAINewsCreated(CapturedOutput output) {
61 | NewsEvent newsEvent = new RAINewsCreated("id", "titolo");
62 | Message newsEventMessage = CloudEventMessageBuilder
63 | .withData(newsEvent)
64 | .setType("com.ivanfranchin.producerservice.news.event.RAINewsCreated")
65 | .setHeader(PARTITION_KEY, "id")
66 | .build();
67 |
68 | inputDestination.send(newsEventMessage, DESTINATION_NAME);
69 |
70 | assertThat(output).contains("Ricevuta notizia creata messaggio da RAI!");
71 | assertThat(output).contains("PAYLOAD: RAINewsCreated[id=id, titolo=titolo]");
72 | }
73 |
74 | private static final String DESTINATION_NAME = "news.events";
75 | private static final String PARTITION_KEY = "partitionKey";
76 | }
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 |
3 | zookeeper:
4 | image: 'confluentinc/cp-zookeeper:7.8.0'
5 | container_name: 'zookeeper'
6 | ports:
7 | - '2181:2181'
8 | environment:
9 | - 'ZOOKEEPER_CLIENT_PORT=2181'
10 | healthcheck:
11 | test: 'echo stat | nc localhost $$ZOOKEEPER_CLIENT_PORT'
12 |
13 | kafka:
14 | image: 'confluentinc/cp-kafka:7.8.0'
15 | container_name: 'kafka'
16 | depends_on:
17 | - 'zookeeper'
18 | ports:
19 | - '9092:9092'
20 | - '29092:29092'
21 | environment:
22 | - 'KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181'
23 | - 'KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
24 | - 'KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092'
25 | - 'KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1'
26 | healthcheck:
27 | test: [ "CMD", "nc", "-z", "localhost", "9092" ]
28 |
29 | kafdrop:
30 | image: 'obsidiandynamics/kafdrop:4.0.2'
31 | container_name: 'kafdrop'
32 | depends_on:
33 | - 'kafka'
34 | ports:
35 | - '9000:9000'
36 | environment:
37 | - 'KAFKA_BROKERCONNECT=kafka:9092'
38 | healthcheck:
39 | test: 'curl -f http://localhost:9000 || exit 1'
40 |
--------------------------------------------------------------------------------
/documentation/project-diagram.excalidraw:
--------------------------------------------------------------------------------
1 | {
2 | "type": "excalidraw",
3 | "version": 2,
4 | "source": "https://excalidraw.com",
5 | "elements": [
6 | {
7 | "type": "rectangle",
8 | "version": 1339,
9 | "versionNonce": 1070874319,
10 | "isDeleted": false,
11 | "id": "htH4DvpAlw_lK0WCfkn_y",
12 | "fillStyle": "hachure",
13 | "strokeWidth": 1,
14 | "strokeStyle": "solid",
15 | "roughness": 1,
16 | "opacity": 100,
17 | "angle": 0,
18 | "x": 349.64219665527344,
19 | "y": -75.34037780761719,
20 | "strokeColor": "#000000",
21 | "backgroundColor": "#4c6ef5",
22 | "width": 496,
23 | "height": 322,
24 | "seed": 209585254,
25 | "groupIds": [],
26 | "roundness": {
27 | "type": 3
28 | },
29 | "boundElements": [
30 | {
31 | "type": "text",
32 | "id": "2X2Ld8TDu-NPJPkdyJl5g"
33 | }
34 | ],
35 | "updated": 1682291449288,
36 | "link": null,
37 | "locked": false
38 | },
39 | {
40 | "type": "text",
41 | "version": 1309,
42 | "versionNonce": 1603689953,
43 | "isDeleted": false,
44 | "id": "2X2Ld8TDu-NPJPkdyJl5g",
45 | "fillStyle": "hachure",
46 | "strokeWidth": 1,
47 | "strokeStyle": "solid",
48 | "roughness": 1,
49 | "opacity": 100,
50 | "angle": 0,
51 | "x": 486.1182403564453,
52 | "y": -70.34037780761719,
53 | "strokeColor": "#000000",
54 | "backgroundColor": "transparent",
55 | "width": 223.04791259765625,
56 | "height": 33.6,
57 | "seed": 200323745,
58 | "groupIds": [],
59 | "roundness": null,
60 | "boundElements": [],
61 | "updated": 1682291440631,
62 | "link": null,
63 | "locked": false,
64 | "fontSize": 28,
65 | "fontFamily": 1,
66 | "text": "consumer-service",
67 | "textAlign": "center",
68 | "verticalAlign": "top",
69 | "containerId": "htH4DvpAlw_lK0WCfkn_y",
70 | "originalText": "consumer-service",
71 | "lineHeight": 1.2,
72 | "baseline": 24
73 | },
74 | {
75 | "type": "rectangle",
76 | "version": 1442,
77 | "versionNonce": 780328783,
78 | "isDeleted": false,
79 | "id": "NKmNZxYxWMCKh3prRiPwX",
80 | "fillStyle": "hachure",
81 | "strokeWidth": 1,
82 | "strokeStyle": "solid",
83 | "roughness": 1,
84 | "opacity": 100,
85 | "angle": 0,
86 | "x": 23.070090433405426,
87 | "y": -66.47324253207717,
88 | "strokeColor": "#000000",
89 | "backgroundColor": "#fab005",
90 | "width": 209.18356323242188,
91 | "height": 99.67071533203125,
92 | "seed": 1526615674,
93 | "groupIds": [],
94 | "roundness": {
95 | "type": 3
96 | },
97 | "boundElements": [
98 | {
99 | "type": "text",
100 | "id": "C9YTYTHGgwpszLfgIFDTi"
101 | },
102 | {
103 | "id": "xXU_6Rj3FN-NZUGt0Me-F",
104 | "type": "arrow"
105 | },
106 | {
107 | "id": "ZnIgjeih3OZaoef4W3KXF",
108 | "type": "arrow"
109 | }
110 | ],
111 | "updated": 1682291454485,
112 | "link": null,
113 | "locked": false
114 | },
115 | {
116 | "type": "text",
117 | "version": 1480,
118 | "versionNonce": 417339151,
119 | "isDeleted": false,
120 | "id": "C9YTYTHGgwpszLfgIFDTi",
121 | "fillStyle": "hachure",
122 | "strokeWidth": 1,
123 | "strokeStyle": "solid",
124 | "roughness": 1,
125 | "opacity": 100,
126 | "angle": 0,
127 | "x": 64.60590281133511,
128 | "y": -50.237884866061535,
129 | "strokeColor": "#000000",
130 | "backgroundColor": "transparent",
131 | "width": 126.1119384765625,
132 | "height": 67.2,
133 | "seed": 672921103,
134 | "groupIds": [],
135 | "roundness": null,
136 | "boundElements": [],
137 | "updated": 1682291452466,
138 | "link": null,
139 | "locked": false,
140 | "fontSize": 28,
141 | "fontFamily": 1,
142 | "text": "producer-\nservice",
143 | "textAlign": "center",
144 | "verticalAlign": "middle",
145 | "containerId": "NKmNZxYxWMCKh3prRiPwX",
146 | "originalText": "producer-\nservice",
147 | "lineHeight": 1.2,
148 | "baseline": 58
149 | },
150 | {
151 | "type": "rectangle",
152 | "version": 1396,
153 | "versionNonce": 1491700655,
154 | "isDeleted": false,
155 | "id": "bUCoZSryR-qLamR-DG4kT",
156 | "fillStyle": "hachure",
157 | "strokeWidth": 1,
158 | "strokeStyle": "solid",
159 | "roughness": 1,
160 | "opacity": 100,
161 | "angle": 0,
162 | "x": -13.756325582219688,
163 | "y": 402.5745354190686,
164 | "strokeColor": "#000000",
165 | "backgroundColor": "#ced4da",
166 | "width": 863,
167 | "height": 144,
168 | "seed": 1526615674,
169 | "groupIds": [],
170 | "roundness": {
171 | "type": 3
172 | },
173 | "boundElements": [
174 | {
175 | "type": "text",
176 | "id": "z145dzn1xiZW6pUlRqOwK"
177 | }
178 | ],
179 | "updated": 1682291350257,
180 | "link": null,
181 | "locked": false
182 | },
183 | {
184 | "type": "text",
185 | "version": 1418,
186 | "versionNonce": 1299789775,
187 | "isDeleted": false,
188 | "id": "z145dzn1xiZW6pUlRqOwK",
189 | "fillStyle": "hachure",
190 | "strokeWidth": 1,
191 | "strokeStyle": "solid",
192 | "roughness": 1,
193 | "opacity": 100,
194 | "angle": 0,
195 | "x": 762.4837256873116,
196 | "y": 407.5745354190686,
197 | "strokeColor": "#000000",
198 | "backgroundColor": "transparent",
199 | "width": 81.75994873046875,
200 | "height": 33.6,
201 | "seed": 672921103,
202 | "groupIds": [],
203 | "roundness": null,
204 | "boundElements": [],
205 | "updated": 1682291341160,
206 | "link": null,
207 | "locked": false,
208 | "fontSize": 28,
209 | "fontFamily": 1,
210 | "text": "Kafka",
211 | "textAlign": "right",
212 | "verticalAlign": "top",
213 | "containerId": "bUCoZSryR-qLamR-DG4kT",
214 | "originalText": "Kafka",
215 | "lineHeight": 1.2,
216 | "baseline": 24
217 | },
218 | {
219 | "type": "rectangle",
220 | "version": 363,
221 | "versionNonce": 805980591,
222 | "isDeleted": false,
223 | "id": "1CbdQL1S0jOZXCdbPPwqQ",
224 | "fillStyle": "hachure",
225 | "strokeWidth": 1,
226 | "strokeStyle": "solid",
227 | "roughness": 1,
228 | "opacity": 100,
229 | "angle": 0,
230 | "x": -2.1284721886650004,
231 | "y": 460.56959157141233,
232 | "strokeColor": "#000000",
233 | "backgroundColor": "#868e96",
234 | "width": 837,
235 | "height": 30,
236 | "seed": 1499097267,
237 | "groupIds": [],
238 | "roundness": {
239 | "type": 3
240 | },
241 | "boundElements": [
242 | {
243 | "type": "text",
244 | "id": "MCW3ax2o9sGYQYQ4WBqDK"
245 | },
246 | {
247 | "id": "xXU_6Rj3FN-NZUGt0Me-F",
248 | "type": "arrow"
249 | }
250 | ],
251 | "updated": 1682291346045,
252 | "link": null,
253 | "locked": false
254 | },
255 | {
256 | "type": "text",
257 | "version": 177,
258 | "versionNonce": 608134639,
259 | "isDeleted": false,
260 | "id": "MCW3ax2o9sGYQYQ4WBqDK",
261 | "fillStyle": "hachure",
262 | "strokeWidth": 1,
263 | "strokeStyle": "solid",
264 | "roughness": 1,
265 | "opacity": 100,
266 | "angle": 0,
267 | "x": 2.8715278113349996,
268 | "y": 465.56959157141233,
269 | "strokeColor": "#000000",
270 | "backgroundColor": "#ced4da",
271 | "width": 90.1119384765625,
272 | "height": 20,
273 | "seed": 491487325,
274 | "groupIds": [],
275 | "roundness": null,
276 | "boundElements": [],
277 | "updated": 1682291341160,
278 | "link": null,
279 | "locked": false,
280 | "fontSize": 16,
281 | "fontFamily": 1,
282 | "text": "news.events",
283 | "textAlign": "left",
284 | "verticalAlign": "middle",
285 | "containerId": "1CbdQL1S0jOZXCdbPPwqQ",
286 | "originalText": "news.events",
287 | "lineHeight": 1.25,
288 | "baseline": 14
289 | },
290 | {
291 | "type": "rectangle",
292 | "version": 395,
293 | "versionNonce": 979030031,
294 | "isDeleted": false,
295 | "id": "MDvRtQliPpuIpq9YCgnRI",
296 | "fillStyle": "hachure",
297 | "strokeWidth": 1,
298 | "strokeStyle": "solid",
299 | "roughness": 1,
300 | "opacity": 100,
301 | "angle": 0,
302 | "x": -2.7016533410087504,
303 | "y": 501.4841423526623,
304 | "strokeColor": "#000000",
305 | "backgroundColor": "#868e96",
306 | "width": 835,
307 | "height": 30,
308 | "seed": 1499097267,
309 | "groupIds": [],
310 | "roundness": {
311 | "type": 3
312 | },
313 | "boundElements": [
314 | {
315 | "type": "text",
316 | "id": "ptJZXnZV1qjeViDTr-DE8"
317 | },
318 | {
319 | "id": "ZnIgjeih3OZaoef4W3KXF",
320 | "type": "arrow"
321 | }
322 | ],
323 | "updated": 1682291341160,
324 | "link": null,
325 | "locked": false
326 | },
327 | {
328 | "type": "text",
329 | "version": 233,
330 | "versionNonce": 1233468513,
331 | "isDeleted": false,
332 | "id": "ptJZXnZV1qjeViDTr-DE8",
333 | "fillStyle": "hachure",
334 | "strokeWidth": 1,
335 | "strokeStyle": "solid",
336 | "roughness": 1,
337 | "opacity": 100,
338 | "angle": 0,
339 | "x": 2.2983466589912496,
340 | "y": 506.4841423526623,
341 | "strokeColor": "#000000",
342 | "backgroundColor": "#ced4da",
343 | "width": 95.0079345703125,
344 | "height": 20,
345 | "seed": 491487325,
346 | "groupIds": [],
347 | "roundness": null,
348 | "boundElements": [],
349 | "updated": 1682291341160,
350 | "link": null,
351 | "locked": false,
352 | "fontSize": 16,
353 | "fontFamily": 1,
354 | "text": "alert.events",
355 | "textAlign": "left",
356 | "verticalAlign": "middle",
357 | "containerId": "MDvRtQliPpuIpq9YCgnRI",
358 | "originalText": "alert.events",
359 | "lineHeight": 1.25,
360 | "baseline": 14
361 | },
362 | {
363 | "type": "arrow",
364 | "version": 1200,
365 | "versionNonce": 1981539681,
366 | "isDeleted": false,
367 | "id": "xXU_6Rj3FN-NZUGt0Me-F",
368 | "fillStyle": "solid",
369 | "strokeWidth": 1,
370 | "strokeStyle": "solid",
371 | "roughness": 1,
372 | "opacity": 100,
373 | "angle": 0,
374 | "x": 64.68083593320037,
375 | "y": 43.75904469641233,
376 | "strokeColor": "#000000",
377 | "backgroundColor": "#ffffff",
378 | "width": 3.849950574170137,
379 | "height": 406.50280761718756,
380 | "seed": 1141640445,
381 | "groupIds": [],
382 | "roundness": {
383 | "type": 2
384 | },
385 | "boundElements": [],
386 | "updated": 1682291452466,
387 | "link": null,
388 | "locked": false,
389 | "startBinding": {
390 | "elementId": "NKmNZxYxWMCKh3prRiPwX",
391 | "focus": 0.5971806522836687,
392 | "gap": 10.561571896458247
393 | },
394 | "endBinding": {
395 | "elementId": "1CbdQL1S0jOZXCdbPPwqQ",
396 | "focus": -0.8498716319941362,
397 | "gap": 10.3077392578125
398 | },
399 | "lastCommittedPoint": null,
400 | "startArrowhead": null,
401 | "endArrowhead": "arrow",
402 | "points": [
403 | [
404 | 0,
405 | 0
406 | ],
407 | [
408 | -0.4724550945216208,
409 | 81.78097534179688
410 | ],
411 | [
412 | -3.849950574170137,
413 | 406.50280761718756
414 | ]
415 | ]
416 | },
417 | {
418 | "type": "arrow",
419 | "version": 1540,
420 | "versionNonce": 320508225,
421 | "isDeleted": false,
422 | "id": "ZnIgjeih3OZaoef4W3KXF",
423 | "fillStyle": "solid",
424 | "strokeWidth": 1,
425 | "strokeStyle": "solid",
426 | "roughness": 1,
427 | "opacity": 100,
428 | "angle": 0,
429 | "x": 138.6157038052129,
430 | "y": 44.70325856359983,
431 | "strokeColor": "#000000",
432 | "backgroundColor": "#ffffff",
433 | "width": 1.0442807434258157,
434 | "height": 451.1919555664062,
435 | "seed": 432801939,
436 | "groupIds": [],
437 | "roundness": {
438 | "type": 2
439 | },
440 | "boundElements": [],
441 | "updated": 1682291452466,
442 | "link": null,
443 | "locked": false,
444 | "startBinding": {
445 | "elementId": "NKmNZxYxWMCKh3prRiPwX",
446 | "focus": -0.10597292440739436,
447 | "gap": 11.505785763645747
448 | },
449 | "endBinding": {
450 | "elementId": "MDvRtQliPpuIpq9YCgnRI",
451 | "focus": -0.6640755007397007,
452 | "gap": 5.58892822265625
453 | },
454 | "lastCommittedPoint": null,
455 | "startArrowhead": null,
456 | "endArrowhead": "arrow",
457 | "points": [
458 | [
459 | 0,
460 | 0
461 | ],
462 | [
463 | -1.0442807434258157,
464 | 451.1919555664062
465 | ]
466 | ]
467 | },
468 | {
469 | "type": "rectangle",
470 | "version": 528,
471 | "versionNonce": 801935023,
472 | "isDeleted": false,
473 | "id": "HFSGVN_W18_KGbB0IA5s-",
474 | "fillStyle": "solid",
475 | "strokeWidth": 1,
476 | "strokeStyle": "solid",
477 | "roughness": 1,
478 | "opacity": 100,
479 | "angle": 0,
480 | "x": 371.40027536992875,
481 | "y": 83.9625053897717,
482 | "strokeColor": "#000000",
483 | "backgroundColor": "#ffffff",
484 | "width": 134,
485 | "height": 70,
486 | "seed": 592322365,
487 | "groupIds": [],
488 | "roundness": {
489 | "type": 3
490 | },
491 | "boundElements": [
492 | {
493 | "type": "text",
494 | "id": "jLcNmNalxOgV8gMyClCdb"
495 | },
496 | {
497 | "id": "geE2W4Xz7SuCD1Jezoev1",
498 | "type": "arrow"
499 | },
500 | {
501 | "id": "iCQtSr6oUh2HB9DEiLT36",
502 | "type": "arrow"
503 | },
504 | {
505 | "id": "L7Q5WJPvrwEFLCtS66jDD",
506 | "type": "arrow"
507 | },
508 | {
509 | "id": "tNfe3HX1DT0VtnSScWR-o",
510 | "type": "arrow"
511 | },
512 | {
513 | "id": "EE6eJscvfzHVauBQ3o7dN",
514 | "type": "arrow"
515 | },
516 | {
517 | "id": "5LEtw9CFUphcEtjDRzYh0",
518 | "type": "arrow"
519 | },
520 | {
521 | "id": "1W7KtnNsYWt3KP6LSp4lV",
522 | "type": "arrow"
523 | }
524 | ],
525 | "updated": 1682291250665,
526 | "link": null,
527 | "locked": false
528 | },
529 | {
530 | "type": "text",
531 | "version": 526,
532 | "versionNonce": 1812740033,
533 | "isDeleted": false,
534 | "id": "jLcNmNalxOgV8gMyClCdb",
535 | "fillStyle": "hachure",
536 | "strokeWidth": 1,
537 | "strokeStyle": "solid",
538 | "roughness": 1,
539 | "opacity": 100,
540 | "angle": 0,
541 | "x": 405.4882956946358,
542 | "y": 88.9625053897717,
543 | "strokeColor": "#000000",
544 | "backgroundColor": "#868e96",
545 | "width": 65.82395935058594,
546 | "height": 60,
547 | "seed": 713831187,
548 | "groupIds": [],
549 | "roundness": null,
550 | "boundElements": [],
551 | "updated": 1682291250665,
552 | "link": null,
553 | "locked": false,
554 | "fontSize": 16,
555 | "fontFamily": 1,
556 | "text": "Message\nRouting\nConfig",
557 | "textAlign": "center",
558 | "verticalAlign": "middle",
559 | "containerId": "HFSGVN_W18_KGbB0IA5s-",
560 | "originalText": "Message\nRouting\nConfig",
561 | "lineHeight": 1.25,
562 | "baseline": 54
563 | },
564 | {
565 | "type": "rectangle",
566 | "version": 437,
567 | "versionNonce": 1892785747,
568 | "isDeleted": false,
569 | "id": "7oY0OUuOL0Kncdql9sDaJ",
570 | "fillStyle": "solid",
571 | "strokeWidth": 1,
572 | "strokeStyle": "solid",
573 | "roughness": 1,
574 | "opacity": 100,
575 | "angle": 0,
576 | "x": 559.6049567664131,
577 | "y": -23.563312481322043,
578 | "strokeColor": "#000000",
579 | "backgroundColor": "#ffffff",
580 | "width": 264,
581 | "height": 37,
582 | "seed": 1263266579,
583 | "groupIds": [],
584 | "roundness": {
585 | "type": 3
586 | },
587 | "boundElements": [
588 | {
589 | "type": "text",
590 | "id": "2Mh6mzV96v5X-iRwTQenu"
591 | },
592 | {
593 | "id": "tNfe3HX1DT0VtnSScWR-o",
594 | "type": "arrow"
595 | }
596 | ],
597 | "updated": 1682237940095,
598 | "link": null,
599 | "locked": false
600 | },
601 | {
602 | "type": "text",
603 | "version": 451,
604 | "versionNonce": 1739659421,
605 | "isDeleted": false,
606 | "id": "2Mh6mzV96v5X-iRwTQenu",
607 | "fillStyle": "solid",
608 | "strokeWidth": 1,
609 | "strokeStyle": "solid",
610 | "roughness": 1,
611 | "opacity": 100,
612 | "angle": 0,
613 | "x": 581.8130332434639,
614 | "y": -15.063312481322043,
615 | "strokeColor": "#000000",
616 | "backgroundColor": "#ffffff",
617 | "width": 219.58384704589844,
618 | "height": 20,
619 | "seed": 283890429,
620 | "groupIds": [],
621 | "roundness": null,
622 | "boundElements": [],
623 | "updated": 1682238052908,
624 | "link": null,
625 | "locked": false,
626 | "fontSize": 16,
627 | "fontFamily": 1,
628 | "text": "cnnNewsCreated ",
629 | "textAlign": "center",
630 | "verticalAlign": "middle",
631 | "containerId": "7oY0OUuOL0Kncdql9sDaJ",
632 | "originalText": "cnnNewsCreated ",
633 | "lineHeight": 1.25,
634 | "baseline": 14
635 | },
636 | {
637 | "type": "rectangle",
638 | "version": 498,
639 | "versionNonce": 1750096883,
640 | "isDeleted": false,
641 | "id": "m74JklvcKoQomibreUHBg",
642 | "fillStyle": "solid",
643 | "strokeWidth": 1,
644 | "strokeStyle": "solid",
645 | "roughness": 1,
646 | "opacity": 100,
647 | "angle": 0,
648 | "x": 559.6049567664131,
649 | "y": 30.27747731359983,
650 | "strokeColor": "#000000",
651 | "backgroundColor": "#ffffff",
652 | "width": 264,
653 | "height": 37,
654 | "seed": 1263266579,
655 | "groupIds": [],
656 | "roundness": {
657 | "type": 3
658 | },
659 | "boundElements": [
660 | {
661 | "type": "text",
662 | "id": "QqLqxjeXqJojzP2NJJtAc"
663 | },
664 | {
665 | "id": "L7Q5WJPvrwEFLCtS66jDD",
666 | "type": "arrow"
667 | }
668 | ],
669 | "updated": 1682237940095,
670 | "link": null,
671 | "locked": false
672 | },
673 | {
674 | "type": "text",
675 | "version": 515,
676 | "versionNonce": 706804467,
677 | "isDeleted": false,
678 | "id": "QqLqxjeXqJojzP2NJJtAc",
679 | "fillStyle": "solid",
680 | "strokeWidth": 1,
681 | "strokeStyle": "solid",
682 | "roughness": 1,
683 | "opacity": 100,
684 | "angle": 0,
685 | "x": 582.7730399573311,
686 | "y": 38.77747731359983,
687 | "strokeColor": "#000000",
688 | "backgroundColor": "#ffffff",
689 | "width": 217.66383361816406,
690 | "height": 20,
691 | "seed": 283890429,
692 | "groupIds": [],
693 | "roundness": null,
694 | "boundElements": [],
695 | "updated": 1682238057360,
696 | "link": null,
697 | "locked": false,
698 | "fontSize": 16,
699 | "fontFamily": 1,
700 | "text": "raiNewsCreated ",
701 | "textAlign": "center",
702 | "verticalAlign": "middle",
703 | "containerId": "m74JklvcKoQomibreUHBg",
704 | "originalText": "raiNewsCreated ",
705 | "lineHeight": 1.25,
706 | "baseline": 14
707 | },
708 | {
709 | "type": "rectangle",
710 | "version": 551,
711 | "versionNonce": 677799315,
712 | "isDeleted": false,
713 | "id": "2xAvCqKhFBCkuG56Scmlc",
714 | "fillStyle": "solid",
715 | "strokeWidth": 1,
716 | "strokeStyle": "solid",
717 | "roughness": 1,
718 | "opacity": 100,
719 | "angle": 0,
720 | "x": 559.6049567664131,
721 | "y": 86.33280568274046,
722 | "strokeColor": "#000000",
723 | "backgroundColor": "#ffffff",
724 | "width": 264,
725 | "height": 37,
726 | "seed": 1263266579,
727 | "groupIds": [],
728 | "roundness": {
729 | "type": 3
730 | },
731 | "boundElements": [
732 | {
733 | "type": "text",
734 | "id": "HJf-SlLF7iN2ykO4mOinh"
735 | },
736 | {
737 | "id": "iCQtSr6oUh2HB9DEiLT36",
738 | "type": "arrow"
739 | }
740 | ],
741 | "updated": 1682237940095,
742 | "link": null,
743 | "locked": false
744 | },
745 | {
746 | "type": "text",
747 | "version": 569,
748 | "versionNonce": 129964595,
749 | "isDeleted": false,
750 | "id": "HJf-SlLF7iN2ykO4mOinh",
751 | "fillStyle": "solid",
752 | "strokeWidth": 1,
753 | "strokeStyle": "solid",
754 | "roughness": 1,
755 | "opacity": 100,
756 | "angle": 0,
757 | "x": 583.8770438635811,
758 | "y": 94.83280568274046,
759 | "strokeColor": "#000000",
760 | "backgroundColor": "#ffffff",
761 | "width": 215.45582580566406,
762 | "height": 20,
763 | "seed": 283890429,
764 | "groupIds": [],
765 | "roundness": null,
766 | "boundElements": [],
767 | "updated": 1682238060875,
768 | "link": null,
769 | "locked": false,
770 | "fontSize": 16,
771 | "fontFamily": 1,
772 | "text": "dwNewsCreated ",
773 | "textAlign": "center",
774 | "verticalAlign": "middle",
775 | "containerId": "2xAvCqKhFBCkuG56Scmlc",
776 | "originalText": "dwNewsCreated ",
777 | "lineHeight": 1.25,
778 | "baseline": 14
779 | },
780 | {
781 | "type": "rectangle",
782 | "version": 623,
783 | "versionNonce": 944388915,
784 | "isDeleted": false,
785 | "id": "lpwvQ6W1SlDDzELv4XJ6B",
786 | "fillStyle": "solid",
787 | "strokeWidth": 1,
788 | "strokeStyle": "solid",
789 | "roughness": 1,
790 | "opacity": 100,
791 | "angle": 0,
792 | "x": 559.6049567664131,
793 | "y": 141.8545952335217,
794 | "strokeColor": "#000000",
795 | "backgroundColor": "#ffffff",
796 | "width": 264,
797 | "height": 37,
798 | "seed": 1263266579,
799 | "groupIds": [],
800 | "roundness": {
801 | "type": 3
802 | },
803 | "boundElements": [
804 | {
805 | "type": "text",
806 | "id": "l4Id8ntp9NLFSC406Nvbj"
807 | },
808 | {
809 | "id": "EE6eJscvfzHVauBQ3o7dN",
810 | "type": "arrow"
811 | }
812 | ],
813 | "updated": 1682237940095,
814 | "link": null,
815 | "locked": false
816 | },
817 | {
818 | "type": "text",
819 | "version": 642,
820 | "versionNonce": 1498311027,
821 | "isDeleted": false,
822 | "id": "l4Id8ntp9NLFSC406Nvbj",
823 | "fillStyle": "solid",
824 | "strokeWidth": 1,
825 | "strokeStyle": "solid",
826 | "roughness": 1,
827 | "opacity": 100,
828 | "angle": 0,
829 | "x": 579.96503367071,
830 | "y": 150.3545952335217,
831 | "strokeColor": "#000000",
832 | "backgroundColor": "#ffffff",
833 | "width": 223.27984619140625,
834 | "height": 20,
835 | "seed": 283890429,
836 | "groupIds": [],
837 | "roundness": null,
838 | "boundElements": [],
839 | "updated": 1682238064111,
840 | "link": null,
841 | "locked": false,
842 | "fontSize": 16,
843 | "fontFamily": 1,
844 | "text": "earthquakeAlert ",
845 | "textAlign": "center",
846 | "verticalAlign": "middle",
847 | "containerId": "lpwvQ6W1SlDDzELv4XJ6B",
848 | "originalText": "earthquakeAlert ",
849 | "lineHeight": 1.25,
850 | "baseline": 14
851 | },
852 | {
853 | "type": "rectangle",
854 | "version": 711,
855 | "versionNonce": 1952603347,
856 | "isDeleted": false,
857 | "id": "oyXvk8E5cJtD_qC9eetbt",
858 | "fillStyle": "solid",
859 | "strokeWidth": 1,
860 | "strokeStyle": "solid",
861 | "roughness": 1,
862 | "opacity": 100,
863 | "angle": 0,
864 | "x": 559.6049567664131,
865 | "y": 194.37491994055296,
866 | "strokeColor": "#000000",
867 | "backgroundColor": "#ffffff",
868 | "width": 264,
869 | "height": 37,
870 | "seed": 1263266579,
871 | "groupIds": [],
872 | "roundness": {
873 | "type": 3
874 | },
875 | "boundElements": [
876 | {
877 | "type": "text",
878 | "id": "9yAhgNUzD3cmzbzAATvNp"
879 | },
880 | {
881 | "id": "5LEtw9CFUphcEtjDRzYh0",
882 | "type": "arrow"
883 | }
884 | ],
885 | "updated": 1682237940095,
886 | "link": null,
887 | "locked": false
888 | },
889 | {
890 | "type": "text",
891 | "version": 731,
892 | "versionNonce": 1480779955,
893 | "isDeleted": false,
894 | "id": "9yAhgNUzD3cmzbzAATvNp",
895 | "fillStyle": "solid",
896 | "strokeWidth": 1,
897 | "strokeStyle": "solid",
898 | "roughness": 1,
899 | "opacity": 100,
900 | "angle": 0,
901 | "x": 593.2370292151436,
902 | "y": 202.87491994055296,
903 | "strokeColor": "#000000",
904 | "backgroundColor": "#ffffff",
905 | "width": 196.73585510253906,
906 | "height": 20,
907 | "seed": 283890429,
908 | "groupIds": [],
909 | "roundness": null,
910 | "boundElements": [],
911 | "updated": 1682238066913,
912 | "link": null,
913 | "locked": false,
914 | "fontSize": 16,
915 | "fontFamily": 1,
916 | "text": "weatherAlert ",
917 | "textAlign": "center",
918 | "verticalAlign": "middle",
919 | "containerId": "oyXvk8E5cJtD_qC9eetbt",
920 | "originalText": "weatherAlert ",
921 | "lineHeight": 1.25,
922 | "baseline": 14
923 | },
924 | {
925 | "type": "arrow",
926 | "version": 957,
927 | "versionNonce": 1675340993,
928 | "isDeleted": false,
929 | "id": "1W7KtnNsYWt3KP6LSp4lV",
930 | "fillStyle": "solid",
931 | "strokeWidth": 1,
932 | "strokeStyle": "solid",
933 | "roughness": 1,
934 | "opacity": 100,
935 | "angle": 0,
936 | "x": 406.32853579107666,
937 | "y": 479.06715016516233,
938 | "strokeColor": "#000000",
939 | "backgroundColor": "#ffffff",
940 | "width": 4.781115304925947,
941 | "height": 312.5096130371094,
942 | "seed": 2034963571,
943 | "groupIds": [],
944 | "roundness": {
945 | "type": 2
946 | },
947 | "boundElements": [],
948 | "updated": 1682291346045,
949 | "link": null,
950 | "locked": false,
951 | "startBinding": null,
952 | "endBinding": {
953 | "elementId": "HFSGVN_W18_KGbB0IA5s-",
954 | "focus": 0.3933113046391834,
955 | "gap": 12.59503173828125
956 | },
957 | "lastCommittedPoint": null,
958 | "startArrowhead": "dot",
959 | "endArrowhead": "arrow",
960 | "points": [
961 | [
962 | 0,
963 | 0
964 | ],
965 | [
966 | 4.781115304925947,
967 | -312.5096130371094
968 | ]
969 | ]
970 | },
971 | {
972 | "type": "arrow",
973 | "version": 926,
974 | "versionNonce": 755600065,
975 | "isDeleted": false,
976 | "id": "geE2W4Xz7SuCD1Jezoev1",
977 | "fillStyle": "solid",
978 | "strokeWidth": 1,
979 | "strokeStyle": "solid",
980 | "roughness": 1,
981 | "opacity": 100,
982 | "angle": 0,
983 | "x": 468.2158255326219,
984 | "y": 516.7293205753185,
985 | "strokeColor": "#000000",
986 | "backgroundColor": "#ffffff",
987 | "width": 3.4242490813762174,
988 | "height": 350.3042907714843,
989 | "seed": 249913117,
990 | "groupIds": [],
991 | "roundness": {
992 | "type": 2
993 | },
994 | "boundElements": [],
995 | "updated": 1682291350258,
996 | "link": null,
997 | "locked": false,
998 | "startBinding": null,
999 | "endBinding": {
1000 | "elementId": "HFSGVN_W18_KGbB0IA5s-",
1001 | "focus": -0.5004853578124121,
1002 | "gap": 12.4625244140625
1003 | },
1004 | "lastCommittedPoint": null,
1005 | "startArrowhead": "dot",
1006 | "endArrowhead": "arrow",
1007 | "points": [
1008 | [
1009 | 0,
1010 | 0
1011 | ],
1012 | [
1013 | 3.4242490813762174,
1014 | -350.3042907714843
1015 | ]
1016 | ]
1017 | },
1018 | {
1019 | "type": "arrow",
1020 | "version": 469,
1021 | "versionNonce": 355331823,
1022 | "isDeleted": false,
1023 | "id": "iCQtSr6oUh2HB9DEiLT36",
1024 | "fillStyle": "solid",
1025 | "strokeWidth": 1,
1026 | "strokeStyle": "solid",
1027 | "roughness": 1,
1028 | "opacity": 100,
1029 | "angle": 0,
1030 | "x": 510.473090311335,
1031 | "y": 109.07756419542429,
1032 | "strokeColor": "#000000",
1033 | "backgroundColor": "#ffffff",
1034 | "width": 46.11151123046875,
1035 | "height": 2.497901656736218,
1036 | "seed": 548431635,
1037 | "groupIds": [],
1038 | "roundness": {
1039 | "type": 2
1040 | },
1041 | "boundElements": [],
1042 | "updated": 1682291250665,
1043 | "link": null,
1044 | "locked": false,
1045 | "startBinding": {
1046 | "elementId": "HFSGVN_W18_KGbB0IA5s-",
1047 | "gap": 5.07281494140625,
1048 | "focus": -0.15069560960158226
1049 | },
1050 | "endBinding": {
1051 | "elementId": "2xAvCqKhFBCkuG56Scmlc",
1052 | "gap": 3.020355224609375,
1053 | "focus": 0.21704464255444336
1054 | },
1055 | "lastCommittedPoint": null,
1056 | "startArrowhead": null,
1057 | "endArrowhead": "arrow",
1058 | "points": [
1059 | [
1060 | 0,
1061 | 0
1062 | ],
1063 | [
1064 | 46.11151123046875,
1065 | -2.497901656736218
1066 | ]
1067 | ]
1068 | },
1069 | {
1070 | "type": "arrow",
1071 | "version": 421,
1072 | "versionNonce": 2123381007,
1073 | "isDeleted": false,
1074 | "id": "L7Q5WJPvrwEFLCtS66jDD",
1075 | "fillStyle": "solid",
1076 | "strokeWidth": 1,
1077 | "strokeStyle": "solid",
1078 | "roughness": 1,
1079 | "opacity": 100,
1080 | "angle": 0,
1081 | "x": 514.8469611609444,
1082 | "y": 89.62444769865992,
1083 | "strokeColor": "#000000",
1084 | "backgroundColor": "#ffffff",
1085 | "width": 35.325103759765625,
1086 | "height": 30.724385210047615,
1087 | "seed": 1931704733,
1088 | "groupIds": [],
1089 | "roundness": {
1090 | "type": 2
1091 | },
1092 | "boundElements": [],
1093 | "updated": 1682291250666,
1094 | "link": null,
1095 | "locked": false,
1096 | "startBinding": {
1097 | "elementId": "HFSGVN_W18_KGbB0IA5s-",
1098 | "gap": 9.446685791015625,
1099 | "focus": 0.40150353775533465
1100 | },
1101 | "endBinding": {
1102 | "elementId": "m74JklvcKoQomibreUHBg",
1103 | "gap": 9.432891845703125,
1104 | "focus": 0.8468346620217315
1105 | },
1106 | "lastCommittedPoint": null,
1107 | "startArrowhead": null,
1108 | "endArrowhead": "arrow",
1109 | "points": [
1110 | [
1111 | 0,
1112 | 0
1113 | ],
1114 | [
1115 | 35.325103759765625,
1116 | -30.724385210047615
1117 | ]
1118 | ]
1119 | },
1120 | {
1121 | "type": "arrow",
1122 | "version": 451,
1123 | "versionNonce": 895762223,
1124 | "isDeleted": false,
1125 | "id": "tNfe3HX1DT0VtnSScWR-o",
1126 | "fillStyle": "solid",
1127 | "strokeWidth": 1,
1128 | "strokeStyle": "solid",
1129 | "roughness": 1,
1130 | "opacity": 100,
1131 | "angle": 0,
1132 | "x": 499.07462600925345,
1133 | "y": 80.6617241397717,
1134 | "strokeColor": "#000000",
1135 | "backgroundColor": "#ffffff",
1136 | "width": 54.3652001419253,
1137 | "height": 77.76728817308216,
1138 | "seed": 370078365,
1139 | "groupIds": [],
1140 | "roundness": {
1141 | "type": 2
1142 | },
1143 | "boundElements": [],
1144 | "updated": 1682291250666,
1145 | "link": null,
1146 | "locked": false,
1147 | "startBinding": {
1148 | "elementId": "HFSGVN_W18_KGbB0IA5s-",
1149 | "gap": 3.30078125,
1150 | "focus": 0.37165833689374717
1151 | },
1152 | "endBinding": {
1153 | "elementId": "7oY0OUuOL0Kncdql9sDaJ",
1154 | "gap": 6.165130615234375,
1155 | "focus": 0.9149204110609095
1156 | },
1157 | "lastCommittedPoint": null,
1158 | "startArrowhead": null,
1159 | "endArrowhead": "arrow",
1160 | "points": [
1161 | [
1162 | 0,
1163 | 0
1164 | ],
1165 | [
1166 | 54.3652001419253,
1167 | -77.76728817308216
1168 | ]
1169 | ]
1170 | },
1171 | {
1172 | "type": "arrow",
1173 | "version": 428,
1174 | "versionNonce": 470974799,
1175 | "isDeleted": false,
1176 | "id": "EE6eJscvfzHVauBQ3o7dN",
1177 | "fillStyle": "solid",
1178 | "strokeWidth": 1,
1179 | "strokeStyle": "solid",
1180 | "roughness": 1,
1181 | "opacity": 100,
1182 | "angle": 0,
1183 | "x": 514.0448371375069,
1184 | "y": 139.0897373372242,
1185 | "strokeColor": "#000000",
1186 | "backgroundColor": "#ffffff",
1187 | "width": 41.657806396484375,
1188 | "height": 18.33827943607338,
1189 | "seed": 1067925821,
1190 | "groupIds": [],
1191 | "roundness": {
1192 | "type": 2
1193 | },
1194 | "boundElements": [],
1195 | "updated": 1682291250666,
1196 | "link": null,
1197 | "locked": false,
1198 | "startBinding": {
1199 | "elementId": "HFSGVN_W18_KGbB0IA5s-",
1200 | "gap": 8.644561767578125,
1201 | "focus": -0.20532419698358598
1202 | },
1203 | "endBinding": {
1204 | "elementId": "lpwvQ6W1SlDDzELv4XJ6B",
1205 | "gap": 3.902313232421875,
1206 | "focus": -0.7427328020960523
1207 | },
1208 | "lastCommittedPoint": null,
1209 | "startArrowhead": null,
1210 | "endArrowhead": "arrow",
1211 | "points": [
1212 | [
1213 | 0,
1214 | 0
1215 | ],
1216 | [
1217 | 41.657806396484375,
1218 | 18.33827943607338
1219 | ]
1220 | ]
1221 | },
1222 | {
1223 | "type": "arrow",
1224 | "version": 436,
1225 | "versionNonce": 871595887,
1226 | "isDeleted": false,
1227 | "id": "5LEtw9CFUphcEtjDRzYh0",
1228 | "fillStyle": "solid",
1229 | "strokeWidth": 1,
1230 | "strokeStyle": "solid",
1231 | "roughness": 1,
1232 | "opacity": 100,
1233 | "angle": 0,
1234 | "x": 513.8280404300449,
1235 | "y": 159.51099428367442,
1236 | "strokeColor": "#000000",
1237 | "backgroundColor": "#ffffff",
1238 | "width": 40.285796951602606,
1239 | "height": 48.92905226085239,
1240 | "seed": 940386547,
1241 | "groupIds": [],
1242 | "roundness": {
1243 | "type": 2
1244 | },
1245 | "boundElements": [],
1246 | "updated": 1682291250666,
1247 | "link": null,
1248 | "locked": false,
1249 | "startBinding": {
1250 | "elementId": "HFSGVN_W18_KGbB0IA5s-",
1251 | "gap": 10.090240478515625,
1252 | "focus": -0.4400858520380315
1253 | },
1254 | "endBinding": {
1255 | "elementId": "oyXvk8E5cJtD_qC9eetbt",
1256 | "gap": 5.491119384765625,
1257 | "focus": -0.9090392188483674
1258 | },
1259 | "lastCommittedPoint": null,
1260 | "startArrowhead": null,
1261 | "endArrowhead": "arrow",
1262 | "points": [
1263 | [
1264 | 0,
1265 | 0
1266 | ],
1267 | [
1268 | 40.285796951602606,
1269 | 48.92905226085239
1270 | ]
1271 | ]
1272 | },
1273 | {
1274 | "id": "uVz6tmIWjleHovRTdkqm-",
1275 | "type": "text",
1276 | "x": -129.43946919794234,
1277 | "y": 54.21766286047483,
1278 | "width": 187.37583923339844,
1279 | "height": 80,
1280 | "angle": 0,
1281 | "strokeColor": "#000000",
1282 | "backgroundColor": "#12b886",
1283 | "fillStyle": "solid",
1284 | "strokeWidth": 1,
1285 | "strokeStyle": "solid",
1286 | "roughness": 1,
1287 | "opacity": 100,
1288 | "groupIds": [],
1289 | "roundness": null,
1290 | "seed": 2040170561,
1291 | "version": 655,
1292 | "versionNonce": 101981711,
1293 | "isDeleted": false,
1294 | "boundElements": null,
1295 | "updated": 1682291312182,
1296 | "link": null,
1297 | "locked": false,
1298 | "text": "CloudEvents: [\ndata: CNNNewsCreated,\nevent_type: ...,\ntimestamp: ...]",
1299 | "fontSize": 16,
1300 | "fontFamily": 1,
1301 | "textAlign": "center",
1302 | "verticalAlign": "top",
1303 | "baseline": 74,
1304 | "containerId": null,
1305 | "originalText": "CloudEvents: [\ndata: CNNNewsCreated,\nevent_type: ...,\ntimestamp: ...]",
1306 | "lineHeight": 1.25
1307 | },
1308 | {
1309 | "type": "text",
1310 | "version": 773,
1311 | "versionNonce": 726650465,
1312 | "isDeleted": false,
1313 | "id": "NSTJcP_Mi7WXWQQIpH5Up",
1314 | "fillStyle": "solid",
1315 | "strokeWidth": 1,
1316 | "strokeStyle": "solid",
1317 | "roughness": 1,
1318 | "opacity": 100,
1319 | "angle": 0,
1320 | "x": -129.01547322626266,
1321 | "y": 167.11198048742796,
1322 | "strokeColor": "#000000",
1323 | "backgroundColor": "#12b886",
1324 | "width": 186.52784729003906,
1325 | "height": 80,
1326 | "seed": 2040170561,
1327 | "groupIds": [],
1328 | "roundness": null,
1329 | "boundElements": [],
1330 | "updated": 1682291312182,
1331 | "link": null,
1332 | "locked": false,
1333 | "fontSize": 16,
1334 | "fontFamily": 1,
1335 | "text": "CloudEvents: [\ndata: RAINewsCreated,\nevent_type: ...,\ntimestamp: ...]",
1336 | "textAlign": "center",
1337 | "verticalAlign": "top",
1338 | "containerId": null,
1339 | "originalText": "CloudEvents: [\ndata: RAINewsCreated,\nevent_type: ...,\ntimestamp: ...]",
1340 | "lineHeight": 1.25,
1341 | "baseline": 74
1342 | },
1343 | {
1344 | "type": "text",
1345 | "version": 824,
1346 | "versionNonce": 1294173231,
1347 | "isDeleted": false,
1348 | "id": "xbUnLsmRMRD0E1NrSTjgD",
1349 | "fillStyle": "solid",
1350 | "strokeWidth": 1,
1351 | "strokeStyle": "solid",
1352 | "roughness": 1,
1353 | "opacity": 100,
1354 | "angle": 0,
1355 | "x": -126.1914833580986,
1356 | "y": 293.40751271399046,
1357 | "strokeColor": "#000000",
1358 | "backgroundColor": "#12b886",
1359 | "width": 180.87986755371094,
1360 | "height": 80,
1361 | "seed": 2040170561,
1362 | "groupIds": [],
1363 | "roundness": null,
1364 | "boundElements": [],
1365 | "updated": 1682291312182,
1366 | "link": null,
1367 | "locked": false,
1368 | "fontSize": 16,
1369 | "fontFamily": 1,
1370 | "text": "CloudEvents: [\ndata: DWNewsCreated,\nevent_type: ...,\ntimestamp: ...]",
1371 | "textAlign": "center",
1372 | "verticalAlign": "top",
1373 | "containerId": null,
1374 | "originalText": "CloudEvents: [\ndata: DWNewsCreated,\nevent_type: ...,\ntimestamp: ...]",
1375 | "lineHeight": 1.25,
1376 | "baseline": 74
1377 | },
1378 | {
1379 | "type": "text",
1380 | "version": 1177,
1381 | "versionNonce": 135660257,
1382 | "isDeleted": false,
1383 | "id": "w7BZGGrUJ9tpTRK8ty1XN",
1384 | "fillStyle": "solid",
1385 | "strokeWidth": 1,
1386 | "strokeStyle": "solid",
1387 | "roughness": 1,
1388 | "opacity": 100,
1389 | "angle": 0,
1390 | "x": 147.7531653845772,
1391 | "y": 66.9302788272717,
1392 | "strokeColor": "#000000",
1393 | "backgroundColor": "#12b886",
1394 | "width": 185.16787719726562,
1395 | "height": 80,
1396 | "seed": 2040170561,
1397 | "groupIds": [],
1398 | "roundness": null,
1399 | "boundElements": [],
1400 | "updated": 1682291319259,
1401 | "link": null,
1402 | "locked": false,
1403 | "fontSize": 16,
1404 | "fontFamily": 1,
1405 | "text": "CloudEvents: [\ndata: EarthquakeAlert,\nevent_type: ...,\ntimestamp: ...]",
1406 | "textAlign": "center",
1407 | "verticalAlign": "top",
1408 | "containerId": null,
1409 | "originalText": "CloudEvents: [\ndata: EarthquakeAlert,\nevent_type: ...,\ntimestamp: ...]",
1410 | "lineHeight": 1.25,
1411 | "baseline": 74
1412 | },
1413 | {
1414 | "type": "text",
1415 | "version": 1112,
1416 | "versionNonce": 778622895,
1417 | "isDeleted": false,
1418 | "id": "S4XJt35ujD4UeYp9l_3VW",
1419 | "fillStyle": "solid",
1420 | "strokeWidth": 1,
1421 | "strokeStyle": "solid",
1422 | "roughness": 1,
1423 | "opacity": 100,
1424 | "angle": 0,
1425 | "x": 160.9451590979561,
1426 | "y": 180.96046071203733,
1427 | "strokeColor": "#000000",
1428 | "backgroundColor": "#12b886",
1429 | "width": 158.7838897705078,
1430 | "height": 80,
1431 | "seed": 2040170561,
1432 | "groupIds": [],
1433 | "roundness": null,
1434 | "boundElements": [],
1435 | "updated": 1682291319260,
1436 | "link": null,
1437 | "locked": false,
1438 | "fontSize": 16,
1439 | "fontFamily": 1,
1440 | "text": "CloudEvents: [\ndata: WeatherAlert,\nevent_type: ...,\ntimestamp: ...]",
1441 | "textAlign": "center",
1442 | "verticalAlign": "top",
1443 | "containerId": null,
1444 | "originalText": "CloudEvents: [\ndata: WeatherAlert,\nevent_type: ...,\ntimestamp: ...]",
1445 | "lineHeight": 1.25,
1446 | "baseline": 74
1447 | }
1448 | ],
1449 | "appState": {
1450 | "gridSize": null,
1451 | "viewBackgroundColor": "#ffffff"
1452 | },
1453 | "files": {}
1454 | }
--------------------------------------------------------------------------------
/documentation/project-diagram.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ivangfr/spring-cloud-stream-event-routing-cloudevents/9ef60c2b6e5df9612618c2786cc85cf52cbd1194/documentation/project-diagram.jpeg
--------------------------------------------------------------------------------
/mvnw:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # ----------------------------------------------------------------------------
3 | # Licensed to the Apache Software Foundation (ASF) under one
4 | # or more contributor license agreements. See the NOTICE file
5 | # distributed with this work for additional information
6 | # regarding copyright ownership. The ASF licenses this file
7 | # to you under the Apache License, Version 2.0 (the
8 | # "License"); you may not use this file except in compliance
9 | # with the License. You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing,
14 | # software distributed under the License is distributed on an
15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16 | # KIND, either express or implied. See the License for the
17 | # specific language governing permissions and limitations
18 | # under the License.
19 | # ----------------------------------------------------------------------------
20 |
21 | # ----------------------------------------------------------------------------
22 | # Apache Maven Wrapper startup batch script, version 3.3.2
23 | #
24 | # Optional ENV vars
25 | # -----------------
26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source
27 | # MVNW_REPOURL - repo url base for downloading maven distribution
28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
30 | # ----------------------------------------------------------------------------
31 |
32 | set -euf
33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x
34 |
35 | # OS specific support.
36 | native_path() { printf %s\\n "$1"; }
37 | case "$(uname)" in
38 | CYGWIN* | MINGW*)
39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
40 | native_path() { cygpath --path --windows "$1"; }
41 | ;;
42 | esac
43 |
44 | # set JAVACMD and JAVACCMD
45 | set_java_home() {
46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
47 | if [ -n "${JAVA_HOME-}" ]; then
48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then
49 | # IBM's JDK on AIX uses strange locations for the executables
50 | JAVACMD="$JAVA_HOME/jre/sh/java"
51 | JAVACCMD="$JAVA_HOME/jre/sh/javac"
52 | else
53 | JAVACMD="$JAVA_HOME/bin/java"
54 | JAVACCMD="$JAVA_HOME/bin/javac"
55 |
56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
59 | return 1
60 | fi
61 | fi
62 | else
63 | JAVACMD="$(
64 | 'set' +e
65 | 'unset' -f command 2>/dev/null
66 | 'command' -v java
67 | )" || :
68 | JAVACCMD="$(
69 | 'set' +e
70 | 'unset' -f command 2>/dev/null
71 | 'command' -v javac
72 | )" || :
73 |
74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
76 | return 1
77 | fi
78 | fi
79 | }
80 |
81 | # hash string like Java String::hashCode
82 | hash_string() {
83 | str="${1:-}" h=0
84 | while [ -n "$str" ]; do
85 | char="${str%"${str#?}"}"
86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
87 | str="${str#?}"
88 | done
89 | printf %x\\n $h
90 | }
91 |
92 | verbose() { :; }
93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
94 |
95 | die() {
96 | printf %s\\n "$1" >&2
97 | exit 1
98 | }
99 |
100 | trim() {
101 | # MWRAPPER-139:
102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
103 | # Needed for removing poorly interpreted newline sequences when running in more
104 | # exotic environments such as mingw bash on Windows.
105 | printf "%s" "${1}" | tr -d '[:space:]'
106 | }
107 |
108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
109 | while IFS="=" read -r key value; do
110 | case "${key-}" in
111 | distributionUrl) distributionUrl=$(trim "${value-}") ;;
112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
113 | esac
114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
116 |
117 | case "${distributionUrl##*/}" in
118 | maven-mvnd-*bin.*)
119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
125 | *)
126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
127 | distributionPlatform=linux-amd64
128 | ;;
129 | esac
130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
131 | ;;
132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
134 | esac
135 |
136 | # apply MVNW_REPOURL and calculate MAVEN_HOME
137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
139 | distributionUrlName="${distributionUrl##*/}"
140 | distributionUrlNameMain="${distributionUrlName%.*}"
141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}"
142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
144 |
145 | exec_maven() {
146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
148 | }
149 |
150 | if [ -d "$MAVEN_HOME" ]; then
151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME"
152 | exec_maven "$@"
153 | fi
154 |
155 | case "${distributionUrl-}" in
156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
158 | esac
159 |
160 | # prepare tmp dir
161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
163 | trap clean HUP INT TERM EXIT
164 | else
165 | die "cannot create temp dir"
166 | fi
167 |
168 | mkdir -p -- "${MAVEN_HOME%/*}"
169 |
170 | # Download and Install Apache Maven
171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
172 | verbose "Downloading from: $distributionUrl"
173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
174 |
175 | # select .zip or .tar.gz
176 | if ! command -v unzip >/dev/null; then
177 | distributionUrl="${distributionUrl%.zip}.tar.gz"
178 | distributionUrlName="${distributionUrl##*/}"
179 | fi
180 |
181 | # verbose opt
182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
184 |
185 | # normalize http auth
186 | case "${MVNW_PASSWORD:+has-password}" in
187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
189 | esac
190 |
191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
192 | verbose "Found wget ... using wget"
193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
195 | verbose "Found curl ... using curl"
196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
197 | elif set_java_home; then
198 | verbose "Falling back to use Java to download"
199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
201 | cat >"$javaSource" <<-END
202 | public class Downloader extends java.net.Authenticator
203 | {
204 | protected java.net.PasswordAuthentication getPasswordAuthentication()
205 | {
206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
207 | }
208 | public static void main( String[] args ) throws Exception
209 | {
210 | setDefault( new Downloader() );
211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
212 | }
213 | }
214 | END
215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java
216 | verbose " - Compiling Downloader.java ..."
217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
218 | verbose " - Running Downloader.java ..."
219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
220 | fi
221 |
222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file
223 | if [ -n "${distributionSha256Sum-}" ]; then
224 | distributionSha256Result=false
225 | if [ "$MVN_CMD" = mvnd.sh ]; then
226 | echo "Checksum validation is not supported for maven-mvnd." >&2
227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
228 | exit 1
229 | elif command -v sha256sum >/dev/null; then
230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
231 | distributionSha256Result=true
232 | fi
233 | elif command -v shasum >/dev/null; then
234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
235 | distributionSha256Result=true
236 | fi
237 | else
238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
240 | exit 1
241 | fi
242 | if [ $distributionSha256Result = false ]; then
243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
245 | exit 1
246 | fi
247 | fi
248 |
249 | # unzip and move
250 | if command -v unzip >/dev/null; then
251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
252 | else
253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
254 | fi
255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
257 |
258 | clean || :
259 | exec_maven "$@"
260 |
--------------------------------------------------------------------------------
/mvnw.cmd:
--------------------------------------------------------------------------------
1 | <# : batch portion
2 | @REM ----------------------------------------------------------------------------
3 | @REM Licensed to the Apache Software Foundation (ASF) under one
4 | @REM or more contributor license agreements. See the NOTICE file
5 | @REM distributed with this work for additional information
6 | @REM regarding copyright ownership. The ASF licenses this file
7 | @REM to you under the Apache License, Version 2.0 (the
8 | @REM "License"); you may not use this file except in compliance
9 | @REM with the License. You may obtain a copy of the License at
10 | @REM
11 | @REM http://www.apache.org/licenses/LICENSE-2.0
12 | @REM
13 | @REM Unless required by applicable law or agreed to in writing,
14 | @REM software distributed under the License is distributed on an
15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16 | @REM KIND, either express or implied. See the License for the
17 | @REM specific language governing permissions and limitations
18 | @REM under the License.
19 | @REM ----------------------------------------------------------------------------
20 |
21 | @REM ----------------------------------------------------------------------------
22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2
23 | @REM
24 | @REM Optional ENV vars
25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution
26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
28 | @REM ----------------------------------------------------------------------------
29 |
30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
31 | @SET __MVNW_CMD__=
32 | @SET __MVNW_ERROR__=
33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
34 | @SET PSModulePath=
35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
37 | )
38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
39 | @SET __MVNW_PSMODULEP_SAVE=
40 | @SET __MVNW_ARG0_NAME__=
41 | @SET MVNW_USERNAME=
42 | @SET MVNW_PASSWORD=
43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
44 | @echo Cannot start maven from wrapper >&2 && exit /b 1
45 | @GOTO :EOF
46 | : end batch / begin powershell #>
47 |
48 | $ErrorActionPreference = "Stop"
49 | if ($env:MVNW_VERBOSE -eq "true") {
50 | $VerbosePreference = "Continue"
51 | }
52 |
53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
55 | if (!$distributionUrl) {
56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
57 | }
58 |
59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
60 | "maven-mvnd-*" {
61 | $USE_MVND = $true
62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
63 | $MVN_CMD = "mvnd.cmd"
64 | break
65 | }
66 | default {
67 | $USE_MVND = $false
68 | $MVN_CMD = $script -replace '^mvnw','mvn'
69 | break
70 | }
71 | }
72 |
73 | # apply MVNW_REPOURL and calculate MAVEN_HOME
74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
75 | if ($env:MVNW_REPOURL) {
76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
78 | }
79 | $distributionUrlName = $distributionUrl -replace '^.*/',''
80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
82 | if ($env:MAVEN_USER_HOME) {
83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
84 | }
85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
87 |
88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
91 | exit $?
92 | }
93 |
94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
96 | }
97 |
98 | # prepare tmp dir
99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
102 | trap {
103 | if ($TMP_DOWNLOAD_DIR.Exists) {
104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
106 | }
107 | }
108 |
109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
110 |
111 | # Download and Install Apache Maven
112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
113 | Write-Verbose "Downloading from: $distributionUrl"
114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
115 |
116 | $webclient = New-Object System.Net.WebClient
117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
119 | }
120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
122 |
123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file
124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
125 | if ($distributionSha256Sum) {
126 | if ($USE_MVND) {
127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
128 | }
129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
132 | }
133 | }
134 |
135 | # unzip and move
136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
138 | try {
139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
140 | } catch {
141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
142 | Write-Error "fail to move MAVEN_HOME"
143 | }
144 | } finally {
145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
147 | }
148 |
149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
150 |
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 | 4.0.0
5 |
6 | org.springframework.boot
7 | spring-boot-starter-parent
8 | 3.4.0
9 |
10 |
11 | com.ivanfranchin
12 | spring-cloud-stream-event-routing-cloudevents
13 | 1.0.0
14 | pom
15 | spring-cloud-stream-event-routing-cloudevents
16 | Demo project for Spring Boot
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 21
32 | 2024.0.0
33 | 4.0.1
34 |
35 |
36 |
37 | io.cloudevents
38 | cloudevents-core
39 | ${cloudevents.version}
40 |
41 |
42 |
43 |
44 |
45 | org.springframework.cloud
46 | spring-cloud-dependencies
47 | ${spring-cloud.version}
48 | pom
49 | import
50 |
51 |
52 |
53 |
54 |
55 | producer-service
56 | consumer-service
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/producer-service/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 | 4.0.0
5 |
6 | com.ivanfranchin
7 | spring-cloud-stream-event-routing-cloudevents
8 | 1.0.0
9 | ../pom.xml
10 |
11 | producer-service
12 | producer-service
13 | Demo project for Spring Boot
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | org.springframework.boot
30 | spring-boot-starter-webflux
31 |
32 |
33 | org.springframework.cloud
34 | spring-cloud-stream
35 |
36 |
37 | org.springframework.cloud
38 | spring-cloud-stream-binder-kafka
39 |
40 |
41 | org.springframework.kafka
42 | spring-kafka
43 |
44 |
45 |
46 | org.springframework.boot
47 | spring-boot-starter-test
48 | test
49 |
50 |
51 | io.projectreactor
52 | reactor-test
53 | test
54 |
55 |
56 | org.springframework.cloud
57 | spring-cloud-stream-test-binder
58 | test
59 |
60 |
61 | org.springframework.kafka
62 | spring-kafka-test
63 | test
64 |
65 |
66 |
67 |
68 |
69 |
70 | org.graalvm.buildtools
71 | native-maven-plugin
72 |
73 |
74 | org.springframework.boot
75 | spring-boot-maven-plugin
76 |
77 |
78 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/ProducerServiceApplication.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice;
2 |
3 | import com.ivanfranchin.producerservice.config.NativeRuntimeHintsRegistrar;
4 | import org.springframework.boot.SpringApplication;
5 | import org.springframework.boot.autoconfigure.SpringBootApplication;
6 | import org.springframework.context.annotation.ImportRuntimeHints;
7 |
8 | @ImportRuntimeHints(NativeRuntimeHintsRegistrar.class)
9 | @SpringBootApplication
10 | public class ProducerServiceApplication {
11 |
12 | public static void main(String[] args) {
13 | SpringApplication.run(ProducerServiceApplication.class, args);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/alert/AlertController.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.alert;
2 |
3 | import com.ivanfranchin.producerservice.alert.event.EarthquakeAlert;
4 | import com.ivanfranchin.producerservice.alert.event.WeatherAlert;
5 | import com.ivanfranchin.producerservice.alert.dto.CreateEarthquakeAlertRequest;
6 | import com.ivanfranchin.producerservice.alert.dto.CreateWeatherAlertRequest;
7 | import jakarta.validation.Valid;
8 | import org.springframework.http.HttpStatus;
9 | import org.springframework.web.bind.annotation.PostMapping;
10 | import org.springframework.web.bind.annotation.RequestBody;
11 | import org.springframework.web.bind.annotation.RequestMapping;
12 | import org.springframework.web.bind.annotation.ResponseStatus;
13 | import org.springframework.web.bind.annotation.RestController;
14 | import reactor.core.publisher.Mono;
15 |
16 | import java.util.UUID;
17 |
18 | @RestController
19 | @RequestMapping("/api/alerts")
20 | public class AlertController {
21 |
22 | private final AlertEventEmitter alertEventEmitter;
23 |
24 | public AlertController(AlertEventEmitter alertEventEmitter) {
25 | this.alertEventEmitter = alertEventEmitter;
26 | }
27 |
28 | @ResponseStatus(HttpStatus.CREATED)
29 | @PostMapping("/earthquake")
30 | public Mono createEarthquakeAlert(@Valid @RequestBody CreateEarthquakeAlertRequest request) {
31 | EarthquakeAlert earthquakeAlert = new EarthquakeAlert(
32 | getId(), request.richterScale(), request.epicenterLat(), request.epicenterLon());
33 | alertEventEmitter.send(earthquakeAlert.id(), earthquakeAlert);
34 | return Mono.just(earthquakeAlert);
35 | }
36 |
37 | @ResponseStatus(HttpStatus.CREATED)
38 | @PostMapping("/weather")
39 | public Mono createWeatherAlert(@Valid @RequestBody CreateWeatherAlertRequest request) {
40 | WeatherAlert weatherAlert = new WeatherAlert(getId(), request.message());
41 | alertEventEmitter.send(weatherAlert.id(), weatherAlert);
42 | return Mono.just(weatherAlert);
43 | }
44 |
45 | private String getId() {
46 | return UUID.randomUUID().toString();
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/alert/AlertEventEmitter.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.alert;
2 |
3 | import com.ivanfranchin.producerservice.alert.event.AlertEvent;
4 | import org.slf4j.Logger;
5 | import org.slf4j.LoggerFactory;
6 | import org.springframework.beans.factory.annotation.Value;
7 | import org.springframework.cloud.function.cloudevent.CloudEventMessageBuilder;
8 | import org.springframework.cloud.stream.function.StreamBridge;
9 | import org.springframework.messaging.Message;
10 | import org.springframework.stereotype.Component;
11 |
12 | @Component
13 | public class AlertEventEmitter {
14 |
15 | private static final Logger log = LoggerFactory.getLogger(AlertEventEmitter.class);
16 |
17 | private final StreamBridge streamBridge;
18 |
19 | public AlertEventEmitter(StreamBridge streamBridge) {
20 | this.streamBridge = streamBridge;
21 | }
22 |
23 | @Value("${spring.cloud.stream.bindings.alert-out-0.destination}")
24 | private String alertKafkaTopic;
25 |
26 | public Message send(String key, AlertEvent alertEvent) {
27 | Message message = CloudEventMessageBuilder.withData(alertEvent)
28 | .setHeader("partitionKey", key)
29 | .build();
30 |
31 | streamBridge.send("alert-out-0", message);
32 | log.info("Sent message '{}' to topic '{}'", message, alertKafkaTopic);
33 | return message;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/alert/dto/CreateEarthquakeAlertRequest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.alert.dto;
2 |
3 | import jakarta.validation.constraints.DecimalMin;
4 | import jakarta.validation.constraints.NotNull;
5 |
6 | public record CreateEarthquakeAlertRequest(@DecimalMin(value = "1.0") Double richterScale,
7 | @NotNull Double epicenterLat,
8 | @NotNull Double epicenterLon) {
9 | }
10 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/alert/dto/CreateWeatherAlertRequest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.alert.dto;
2 |
3 | import jakarta.validation.constraints.NotBlank;
4 |
5 | public record CreateWeatherAlertRequest(@NotBlank String message) {
6 | }
7 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/alert/event/AlertEvent.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.alert.event;
2 |
3 | public interface AlertEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/alert/event/EarthquakeAlert.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.alert.event;
2 |
3 | public record EarthquakeAlert(String id, Double richterScale, Double epicenterLat,
4 | Double epicenterLon) implements AlertEvent {
5 | }
6 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/alert/event/WeatherAlert.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.alert.event;
2 |
3 | public record WeatherAlert(String id, String message) implements AlertEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/config/ErrorAttributesConfig.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.config;
2 |
3 | import org.springframework.boot.web.error.ErrorAttributeOptions;
4 | import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
5 | import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
6 | import org.springframework.boot.web.reactive.error.ErrorAttributes;
7 | import org.springframework.context.annotation.Bean;
8 | import org.springframework.context.annotation.Configuration;
9 | import org.springframework.web.reactive.function.server.ServerRequest;
10 |
11 | import java.util.Map;
12 |
13 | @Configuration
14 | public class ErrorAttributesConfig {
15 |
16 | @Bean
17 | ErrorAttributes errorAttributes() {
18 | return new DefaultErrorAttributes() {
19 | @Override
20 | public Map getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {
21 | return super.getErrorAttributes(request,
22 | options.including(Include.EXCEPTION, Include.MESSAGE, Include.BINDING_ERRORS));
23 | }
24 | };
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/config/NativeRuntimeHintsRegistrar.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.config;
2 |
3 | import org.springframework.aot.hint.ExecutableMode;
4 | import org.springframework.aot.hint.RuntimeHints;
5 | import org.springframework.aot.hint.RuntimeHintsRegistrar;
6 | import org.springframework.messaging.Message;
7 | import org.springframework.util.ReflectionUtils;
8 |
9 | import java.lang.reflect.Method;
10 |
11 | public class NativeRuntimeHintsRegistrar implements RuntimeHintsRegistrar {
12 |
13 | @Override
14 | public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
15 | Method method = ReflectionUtils.findMethod(Message.class, "getHeaders");
16 | hints.reflection().registerMethod(method, ExecutableMode.INVOKE);
17 | }
18 | }
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/news/NewsController.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news;
2 |
3 | import com.ivanfranchin.producerservice.news.dto.CreateCNNNewsRequest;
4 | import com.ivanfranchin.producerservice.news.dto.CreateDWNewsRequest;
5 | import com.ivanfranchin.producerservice.news.dto.CreateRAINewsRequest;
6 | import com.ivanfranchin.producerservice.news.event.CNNNewsCreated;
7 | import com.ivanfranchin.producerservice.news.event.DWNewsCreated;
8 | import com.ivanfranchin.producerservice.news.event.RAINewsCreated;
9 | import jakarta.validation.Valid;
10 | import org.springframework.http.HttpStatus;
11 | import org.springframework.web.bind.annotation.PostMapping;
12 | import org.springframework.web.bind.annotation.RequestBody;
13 | import org.springframework.web.bind.annotation.RequestMapping;
14 | import org.springframework.web.bind.annotation.ResponseStatus;
15 | import org.springframework.web.bind.annotation.RestController;
16 | import reactor.core.publisher.Mono;
17 |
18 | import java.util.UUID;
19 |
20 | @RestController
21 | @RequestMapping("/api/news")
22 | public class NewsController {
23 |
24 | private final NewsEventEmitter newsEventEmitter;
25 |
26 | public NewsController(NewsEventEmitter newsEventEmitter) {
27 | this.newsEventEmitter = newsEventEmitter;
28 | }
29 |
30 | @ResponseStatus(HttpStatus.CREATED)
31 | @PostMapping("/cnn")
32 | public Mono createCNNNews(@Valid @RequestBody CreateCNNNewsRequest request) {
33 | CNNNewsCreated cnnNewsCreated = new CNNNewsCreated(getId(), request.title());
34 | newsEventEmitter.send(cnnNewsCreated.id(), cnnNewsCreated);
35 | return Mono.just(cnnNewsCreated);
36 | }
37 |
38 | @ResponseStatus(HttpStatus.CREATED)
39 | @PostMapping("/dw")
40 | public Mono createDWNews(@Valid @RequestBody CreateDWNewsRequest request) {
41 | DWNewsCreated dwNewsCreated = new DWNewsCreated(getId(), request.titel());
42 | newsEventEmitter.send(dwNewsCreated.id(), dwNewsCreated);
43 | return Mono.just(dwNewsCreated);
44 | }
45 |
46 | @ResponseStatus(HttpStatus.CREATED)
47 | @PostMapping("rai")
48 | public Mono createRAINews(@Valid @RequestBody CreateRAINewsRequest request) {
49 | RAINewsCreated raiNewsCreated = new RAINewsCreated(getId(), request.titolo());
50 | newsEventEmitter.send(raiNewsCreated.id(), raiNewsCreated);
51 | return Mono.just(raiNewsCreated);
52 | }
53 |
54 | private String getId() {
55 | return UUID.randomUUID().toString();
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/news/NewsEventEmitter.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news;
2 |
3 | import com.ivanfranchin.producerservice.news.event.NewsEvent;
4 | import org.slf4j.Logger;
5 | import org.slf4j.LoggerFactory;
6 | import org.springframework.beans.factory.annotation.Value;
7 | import org.springframework.cloud.function.cloudevent.CloudEventMessageBuilder;
8 | import org.springframework.cloud.stream.function.StreamBridge;
9 | import org.springframework.messaging.Message;
10 | import org.springframework.stereotype.Component;
11 |
12 | @Component
13 | public class NewsEventEmitter {
14 |
15 | private static final Logger log = LoggerFactory.getLogger(NewsEventEmitter.class);
16 |
17 | private final StreamBridge streamBridge;
18 |
19 | public NewsEventEmitter(StreamBridge streamBridge) {
20 | this.streamBridge = streamBridge;
21 | }
22 |
23 | @Value("${spring.cloud.stream.bindings.news-out-0.destination}")
24 | private String newsKafkaTopic;
25 |
26 | public Message send(String key, NewsEvent newsEvent) {
27 | Message message = CloudEventMessageBuilder.withData(newsEvent)
28 | .setHeader("partitionKey", key)
29 | .build();
30 |
31 | streamBridge.send("news-out-0", message);
32 | log.info("Sent message '{}' to topic '{}'", message, newsKafkaTopic);
33 | return message;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/news/dto/CreateCNNNewsRequest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news.dto;
2 |
3 | import jakarta.validation.constraints.NotBlank;
4 |
5 | public record CreateCNNNewsRequest(@NotBlank String title) {
6 | }
7 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/news/dto/CreateDWNewsRequest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news.dto;
2 |
3 | import jakarta.validation.constraints.NotBlank;
4 |
5 | public record CreateDWNewsRequest(@NotBlank String titel) {
6 | }
7 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/news/dto/CreateRAINewsRequest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news.dto;
2 |
3 | import jakarta.validation.constraints.NotBlank;
4 |
5 | public record CreateRAINewsRequest(@NotBlank String titolo) {
6 | }
7 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/news/event/CNNNewsCreated.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news.event;
2 |
3 | public record CNNNewsCreated(String id, String title) implements NewsEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/news/event/DWNewsCreated.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news.event;
2 |
3 | public record DWNewsCreated(String id, String titel) implements NewsEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/news/event/NewsEvent.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news.event;
2 |
3 | public interface NewsEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/producer-service/src/main/java/com/ivanfranchin/producerservice/news/event/RAINewsCreated.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news.event;
2 |
3 | public record RAINewsCreated(String id, String titolo) implements NewsEvent {
4 | }
5 |
--------------------------------------------------------------------------------
/producer-service/src/main/resources/application.properties:
--------------------------------------------------------------------------------
1 | server.port=9080
2 |
3 | spring.application.name=producer-service
4 |
5 | spring.cloud.stream.kafka.binder.brokers=${KAFKA_HOST:localhost}:${KAFKA_PORT:29092}
6 |
7 | spring.cloud.stream.output-bindings=news-out-0;alert-out-0
8 |
9 | spring.cloud.stream.bindings.news-out-0.destination=news.events
10 | spring.cloud.stream.bindings.news-out-0.content-type=application/json
11 | spring.cloud.stream.bindings.news-out-0.producer.partition-key-expression=headers['partitionKey']
12 | spring.cloud.stream.bindings.news-out-0.producer.partition-count=3
13 |
14 | spring.cloud.stream.bindings.alert-out-0.destination=alert.events
15 | spring.cloud.stream.bindings.alert-out-0.content-type=application/json
16 | spring.cloud.stream.bindings.alert-out-0.producer.partition-key-expression=headers['partitionKey']
17 | spring.cloud.stream.bindings.alert-out-0.producer.partition-count=3
18 |
--------------------------------------------------------------------------------
/producer-service/src/main/resources/banner.txt:
--------------------------------------------------------------------------------
1 | _ _
2 | _ __ _ __ ___ __| |_ _ ___ ___ _ __ ___ ___ _ ____ _(_) ___ ___
3 | | '_ \| '__/ _ \ / _` | | | |/ __/ _ \ '__|____/ __|/ _ \ '__\ \ / / |/ __/ _ \
4 | | |_) | | | (_) | (_| | |_| | (_| __/ | |_____\__ \ __/ | \ V /| | (_| __/
5 | | .__/|_| \___/ \__,_|\__,_|\___\___|_| |___/\___|_| \_/ |_|\___\___|
6 | |_|
7 | :: Spring Boot :: ${spring-boot.formatted-version}
8 |
--------------------------------------------------------------------------------
/producer-service/src/test/java/com/ivanfranchin/producerservice/alert/AlertControllerTest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.alert;
2 |
3 | import com.ivanfranchin.producerservice.alert.event.EarthquakeAlert;
4 | import com.ivanfranchin.producerservice.alert.event.WeatherAlert;
5 | import com.ivanfranchin.producerservice.alert.dto.CreateEarthquakeAlertRequest;
6 | import com.ivanfranchin.producerservice.alert.dto.CreateWeatherAlertRequest;
7 | import org.junit.jupiter.api.Test;
8 | import org.junit.jupiter.api.extension.ExtendWith;
9 | import org.springframework.beans.factory.annotation.Autowired;
10 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
11 | import org.springframework.http.MediaType;
12 | import org.springframework.test.context.bean.override.mockito.MockitoBean;
13 | import org.springframework.test.context.junit.jupiter.SpringExtension;
14 | import org.springframework.test.web.reactive.server.WebTestClient;
15 | import reactor.core.publisher.Mono;
16 |
17 | import static org.assertj.core.api.Assertions.assertThat;
18 |
19 | @ExtendWith(SpringExtension.class)
20 | @WebFluxTest(AlertController.class)
21 | class AlertControllerTest {
22 |
23 | @Autowired
24 | private WebTestClient webTestClient;
25 |
26 | @MockitoBean
27 | private AlertEventEmitter alertEventEmitter;
28 |
29 | @Test
30 | void testCreateEarthquakeAlert() {
31 | CreateEarthquakeAlertRequest request = new CreateEarthquakeAlertRequest(2.1, 1.0, -1.0);
32 |
33 | webTestClient.post()
34 | .uri(BASE_URL + "/earthquake")
35 | .accept(MediaType.APPLICATION_JSON)
36 | .body(Mono.just(request), CreateEarthquakeAlertRequest.class)
37 | .exchange()
38 | .expectStatus().isCreated()
39 | .expectBody(EarthquakeAlert.class)
40 | .value(response -> {
41 | assertThat(response.id()).isNotNull();
42 | assertThat(response.richterScale()).isEqualTo(request.richterScale());
43 | assertThat(response.epicenterLat()).isEqualTo(request.epicenterLat());
44 | assertThat(response.epicenterLon()).isEqualTo(request.epicenterLon());
45 | });
46 | }
47 |
48 | @Test
49 | void testCreateWeatherAlert() {
50 | CreateWeatherAlertRequest request = new CreateWeatherAlertRequest("message");
51 |
52 | webTestClient.post()
53 | .uri(BASE_URL + "/weather")
54 | .accept(MediaType.APPLICATION_JSON)
55 | .body(Mono.just(request), CreateEarthquakeAlertRequest.class)
56 | .exchange()
57 | .expectStatus().isCreated()
58 | .expectBody(WeatherAlert.class)
59 | .value(response -> {
60 | assertThat(response.id()).isNotNull();
61 | assertThat(response.message()).isEqualTo(request.message());
62 | });
63 | }
64 |
65 | private static final String BASE_URL = "/api/alerts";
66 | }
--------------------------------------------------------------------------------
/producer-service/src/test/java/com/ivanfranchin/producerservice/alert/AlertEventEmitterTest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.alert;
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper;
4 | import com.ivanfranchin.producerservice.ProducerServiceApplication;
5 | import com.ivanfranchin.producerservice.alert.event.EarthquakeAlert;
6 | import com.ivanfranchin.producerservice.alert.event.WeatherAlert;
7 | import org.junit.jupiter.api.Test;
8 | import org.springframework.boot.WebApplicationType;
9 | import org.springframework.boot.builder.SpringApplicationBuilder;
10 | import org.springframework.cloud.function.cloudevent.CloudEventMessageUtils;
11 | import org.springframework.cloud.stream.binder.test.OutputDestination;
12 | import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration;
13 | import org.springframework.context.ConfigurableApplicationContext;
14 | import org.springframework.http.MediaType;
15 | import org.springframework.messaging.Message;
16 | import org.springframework.messaging.MessageHeaders;
17 |
18 | import java.io.IOException;
19 | import java.net.URI;
20 |
21 | import static org.assertj.core.api.Assertions.assertThat;
22 |
23 | class AlertEventEmitterTest {
24 |
25 | @Test
26 | void testSendEarthquakeAlert() {
27 | try (ConfigurableApplicationContext context = new SpringApplicationBuilder(
28 | TestChannelBinderConfiguration.getCompleteConfiguration(
29 | ProducerServiceApplication.class))
30 | .web(WebApplicationType.NONE)
31 | .run("--spring.jmx.enabled=false")) {
32 |
33 | AlertEventEmitter alertEventEmitter = context.getBean(AlertEventEmitter.class);
34 | EarthquakeAlert earthquakeAlert = new EarthquakeAlert("id", 2.1, 1.0, -1.0);
35 | alertEventEmitter.send(earthquakeAlert.id(), earthquakeAlert);
36 |
37 | ObjectMapper objectMapper = context.getBean(ObjectMapper.class);
38 | OutputDestination outputDestination = context.getBean(OutputDestination.class);
39 |
40 | Message outputMessage = outputDestination.receive(0, BINDING_NAME);
41 |
42 | MessageHeaders headers = outputMessage.getHeaders();
43 | assertThat(headers.get(PARTITION_KEY)).isEqualTo("id");
44 | assertThat(headers.get(MessageHeaders.CONTENT_TYPE)).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
45 |
46 | assertThat(CloudEventMessageUtils.getSource(outputMessage)).isEqualTo(SOURCE_URI);
47 | assertThat(CloudEventMessageUtils.getSpecVersion(outputMessage)).isEqualTo(VERSION_1_0);
48 | assertThat(CloudEventMessageUtils.getType(outputMessage)).isEqualTo(EarthquakeAlert.class.getName());
49 | assertThat(CloudEventMessageUtils.getId(outputMessage)).isNotNull();
50 |
51 | EarthquakeAlert payload = deserialize(objectMapper, outputMessage.getPayload(), EarthquakeAlert.class);
52 | assertThat(payload).isNotNull();
53 | assertThat(payload.id()).isEqualTo(earthquakeAlert.id());
54 | assertThat(payload.richterScale()).isEqualTo(earthquakeAlert.richterScale());
55 | assertThat(payload.epicenterLat()).isEqualTo(earthquakeAlert.epicenterLat());
56 | assertThat(payload.epicenterLon()).isEqualTo(earthquakeAlert.epicenterLon());
57 | }
58 | }
59 |
60 | @Test
61 | void testSendWeatherAlert() {
62 | try (ConfigurableApplicationContext context = new SpringApplicationBuilder(
63 | TestChannelBinderConfiguration.getCompleteConfiguration(
64 | ProducerServiceApplication.class))
65 | .web(WebApplicationType.NONE)
66 | .run("--spring.jmx.enabled=false")) {
67 |
68 | AlertEventEmitter alertEventEmitter = context.getBean(AlertEventEmitter.class);
69 | WeatherAlert weatherAlert = new WeatherAlert("id", "message");
70 | alertEventEmitter.send(weatherAlert.id(), weatherAlert);
71 |
72 | ObjectMapper objectMapper = context.getBean(ObjectMapper.class);
73 | OutputDestination outputDestination = context.getBean(OutputDestination.class);
74 |
75 | Message outputMessage = outputDestination.receive(0, BINDING_NAME);
76 |
77 | MessageHeaders headers = outputMessage.getHeaders();
78 | assertThat(headers.get(MessageHeaders.CONTENT_TYPE)).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
79 | assertThat(headers.get(PARTITION_KEY)).isEqualTo("id");
80 |
81 | assertThat(CloudEventMessageUtils.getSource(outputMessage)).isEqualTo(SOURCE_URI);
82 | assertThat(CloudEventMessageUtils.getSpecVersion(outputMessage)).isEqualTo(VERSION_1_0);
83 | assertThat(CloudEventMessageUtils.getType(outputMessage)).isEqualTo(WeatherAlert.class.getName());
84 | assertThat(CloudEventMessageUtils.getId(outputMessage)).isNotNull();
85 |
86 | WeatherAlert payload = deserialize(objectMapper, outputMessage.getPayload(), WeatherAlert.class);
87 | assertThat(payload).isNotNull();
88 | assertThat(payload.id()).isEqualTo(weatherAlert.id());
89 | assertThat(payload.message()).isEqualTo(weatherAlert.message());
90 | }
91 | }
92 |
93 | private T deserialize(ObjectMapper objectMapper, byte[] bytes, Class clazz) {
94 | try {
95 | return objectMapper.readValue(bytes, clazz);
96 | } catch (IOException e) {
97 | return null;
98 | }
99 | }
100 |
101 | private static final String BINDING_NAME = "alert.events";
102 | private static final String VERSION_1_0 = "1.0";
103 | private static final URI SOURCE_URI = URI.create("https://spring.io/");
104 | private static final String PARTITION_KEY = "partitionKey";
105 | }
--------------------------------------------------------------------------------
/producer-service/src/test/java/com/ivanfranchin/producerservice/news/NewsControllerTest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news;
2 |
3 | import com.ivanfranchin.producerservice.news.dto.CreateCNNNewsRequest;
4 | import com.ivanfranchin.producerservice.news.dto.CreateDWNewsRequest;
5 | import com.ivanfranchin.producerservice.news.dto.CreateRAINewsRequest;
6 | import com.ivanfranchin.producerservice.news.event.CNNNewsCreated;
7 | import com.ivanfranchin.producerservice.news.event.DWNewsCreated;
8 | import com.ivanfranchin.producerservice.news.event.RAINewsCreated;
9 | import org.junit.jupiter.api.Test;
10 | import org.junit.jupiter.api.extension.ExtendWith;
11 | import org.springframework.beans.factory.annotation.Autowired;
12 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
13 | import org.springframework.http.MediaType;
14 | import org.springframework.test.context.bean.override.mockito.MockitoBean;
15 | import org.springframework.test.context.junit.jupiter.SpringExtension;
16 | import org.springframework.test.web.reactive.server.WebTestClient;
17 | import reactor.core.publisher.Mono;
18 |
19 | import static org.assertj.core.api.Assertions.assertThat;
20 |
21 | @ExtendWith(SpringExtension.class)
22 | @WebFluxTest(NewsController.class)
23 | class NewsControllerTest {
24 |
25 | @Autowired
26 | private WebTestClient webTestClient;
27 |
28 | @MockitoBean
29 | private NewsEventEmitter newsEventEmitter;
30 |
31 | @Test
32 | void testCreateCNNNews() {
33 | CreateCNNNewsRequest request = new CreateCNNNewsRequest("title");
34 |
35 | webTestClient.post()
36 | .uri(BASE_URL + "/cnn")
37 | .accept(MediaType.APPLICATION_JSON)
38 | .body(Mono.just(request), CreateCNNNewsRequest.class)
39 | .exchange()
40 | .expectStatus().isCreated()
41 | .expectBody(CNNNewsCreated.class)
42 | .value(response -> {
43 | assertThat(response.id()).isNotNull();
44 | assertThat(response.title()).isEqualTo(request.title());
45 | });
46 | }
47 |
48 | @Test
49 | void testCreateDWNews() {
50 | CreateDWNewsRequest request = new CreateDWNewsRequest("titel");
51 |
52 | webTestClient.post()
53 | .uri(BASE_URL + "/dw")
54 | .accept(MediaType.APPLICATION_JSON)
55 | .body(Mono.just(request), CreateDWNewsRequest.class)
56 | .exchange()
57 | .expectStatus().isCreated()
58 | .expectBody(DWNewsCreated.class)
59 | .value(response -> {
60 | assertThat(response.id()).isNotNull();
61 | assertThat(response.titel()).isEqualTo(request.titel());
62 | });
63 | }
64 |
65 | @Test
66 | void testCreateRAINews() {
67 | CreateRAINewsRequest request = new CreateRAINewsRequest("titolo");
68 |
69 | webTestClient.post()
70 | .uri(BASE_URL + "/rai")
71 | .accept(MediaType.APPLICATION_JSON)
72 | .body(Mono.just(request), CreateDWNewsRequest.class)
73 | .exchange()
74 | .expectStatus().isCreated()
75 | .expectBody(RAINewsCreated.class)
76 | .value(response -> {
77 | assertThat(response.id()).isNotNull();
78 | assertThat(response.titolo()).isEqualTo(request.titolo());
79 | });
80 | }
81 |
82 | private static final String BASE_URL = "/api/news";
83 | }
--------------------------------------------------------------------------------
/producer-service/src/test/java/com/ivanfranchin/producerservice/news/NewsEventEmitterTest.java:
--------------------------------------------------------------------------------
1 | package com.ivanfranchin.producerservice.news;
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper;
4 | import com.ivanfranchin.producerservice.news.event.CNNNewsCreated;
5 | import com.ivanfranchin.producerservice.news.event.DWNewsCreated;
6 | import com.ivanfranchin.producerservice.news.event.RAINewsCreated;
7 | import org.junit.jupiter.api.Test;
8 | import org.springframework.beans.factory.annotation.Autowired;
9 | import org.springframework.boot.test.context.SpringBootTest;
10 | import org.springframework.cloud.function.cloudevent.CloudEventMessageUtils;
11 | import org.springframework.cloud.stream.binder.test.OutputDestination;
12 | import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration;
13 | import org.springframework.context.annotation.Import;
14 | import org.springframework.http.MediaType;
15 | import org.springframework.messaging.Message;
16 | import org.springframework.messaging.MessageHeaders;
17 |
18 | import java.io.IOException;
19 | import java.net.URI;
20 |
21 | import static org.assertj.core.api.Assertions.assertThat;
22 |
23 | @SpringBootTest
24 | @Import(TestChannelBinderConfiguration.class)
25 | class NewsEventEmitterTest {
26 |
27 | @Autowired
28 | private OutputDestination outputDestination;
29 |
30 | @Autowired
31 | private NewsEventEmitter newsEventEmitter;
32 |
33 | @Autowired
34 | private ObjectMapper objectMapper;
35 |
36 | @Test
37 | void testSendCNNNewsCreated() throws IOException {
38 | CNNNewsCreated cnnNewsCreated = new CNNNewsCreated("id", "title");
39 | newsEventEmitter.send(cnnNewsCreated.id(), cnnNewsCreated);
40 |
41 | Message outputMessage = outputDestination.receive(0, "news.events");
42 |
43 | MessageHeaders headers = outputMessage.getHeaders();
44 | assertThat(headers.get(MessageHeaders.CONTENT_TYPE)).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
45 | assertThat(headers.get(PARTITION_KEY)).isEqualTo("id");
46 |
47 | assertThat(CloudEventMessageUtils.getSource(outputMessage)).isEqualTo(SOURCE_URI);
48 | assertThat(CloudEventMessageUtils.getSpecVersion(outputMessage)).isEqualTo(VERSION_1_0);
49 | assertThat(CloudEventMessageUtils.getType(outputMessage)).isEqualTo(CNNNewsCreated.class.getName());
50 | assertThat(CloudEventMessageUtils.getId(outputMessage)).isNotNull();
51 |
52 | CNNNewsCreated payload = objectMapper.readValue(outputMessage.getPayload(), CNNNewsCreated.class);
53 | assertThat(payload.id()).isEqualTo(cnnNewsCreated.id());
54 | assertThat(payload.title()).isEqualTo(cnnNewsCreated.title());
55 | }
56 |
57 | @Test
58 | void testSendDWNewsCreated() throws IOException {
59 | DWNewsCreated dwNewsCreated = new DWNewsCreated("id", "titel");
60 | newsEventEmitter.send(dwNewsCreated.id(), dwNewsCreated);
61 |
62 | Message outputMessage = outputDestination.receive(0, BINDING_NAME);
63 |
64 | MessageHeaders headers = outputMessage.getHeaders();
65 | assertThat(headers.get(MessageHeaders.CONTENT_TYPE)).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
66 | assertThat(headers.get(PARTITION_KEY)).isEqualTo("id");
67 |
68 | assertThat(CloudEventMessageUtils.getSource(outputMessage)).isEqualTo(SOURCE_URI);
69 | assertThat(CloudEventMessageUtils.getSpecVersion(outputMessage)).isEqualTo(VERSION_1_0);
70 | assertThat(CloudEventMessageUtils.getType(outputMessage)).isEqualTo(DWNewsCreated.class.getName());
71 | assertThat(CloudEventMessageUtils.getId(outputMessage)).isNotNull();
72 |
73 | DWNewsCreated payload = objectMapper.readValue(outputMessage.getPayload(), DWNewsCreated.class);
74 | assertThat(payload.id()).isEqualTo(dwNewsCreated.id());
75 | assertThat(payload.titel()).isEqualTo(dwNewsCreated.titel());
76 | }
77 |
78 | @Test
79 | void testSendRAINewsCreated() throws IOException {
80 | RAINewsCreated raiNewsCreated = new RAINewsCreated("id", "titolo");
81 | newsEventEmitter.send(raiNewsCreated.id(), raiNewsCreated);
82 |
83 | Message outputMessage = outputDestination.receive(0, BINDING_NAME);
84 |
85 | MessageHeaders headers = outputMessage.getHeaders();
86 | assertThat(headers.get(MessageHeaders.CONTENT_TYPE)).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
87 | assertThat(headers.get(PARTITION_KEY)).isEqualTo("id");
88 |
89 | assertThat(CloudEventMessageUtils.getSource(outputMessage)).isEqualTo(SOURCE_URI);
90 | assertThat(CloudEventMessageUtils.getSpecVersion(outputMessage)).isEqualTo(VERSION_1_0);
91 | assertThat(CloudEventMessageUtils.getType(outputMessage)).isEqualTo(RAINewsCreated.class.getName());
92 | assertThat(CloudEventMessageUtils.getId(outputMessage)).isNotNull();
93 |
94 | RAINewsCreated payload = objectMapper.readValue(outputMessage.getPayload(), RAINewsCreated.class);
95 | assertThat(payload.id()).isEqualTo(raiNewsCreated.id());
96 | assertThat(payload.titolo()).isEqualTo(raiNewsCreated.titolo());
97 | }
98 |
99 | private static final String BINDING_NAME = "news.events";
100 | private static final String VERSION_1_0 = "1.0";
101 | private static final URI SOURCE_URI = URI.create("https://spring.io/");
102 | private static final String PARTITION_KEY = "partitionKey";
103 | }
--------------------------------------------------------------------------------
/remove-docker-images.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | docker rmi ivanfranchin/producer-service:1.0.0
4 | docker rmi ivanfranchin/consumer-service:1.0.0
5 |
--------------------------------------------------------------------------------