├── .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 | ![project-diagram](documentation/project-diagram.jpeg) 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 | --------------------------------------------------------------------------------