├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── README.md ├── build-docker-images.sh ├── docker-compose.yml ├── documentation ├── demo.gif ├── project-diagram.excalidraw └── project-diagram.jpeg ├── dynamodb-lambda-function ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── dynamodblambdafunction │ │ │ ├── DynamodbLambdaFunctionApplication.java │ │ │ ├── handler │ │ │ └── DynamodbEventHandler.java │ │ │ ├── news │ │ │ ├── NewsEvent.java │ │ │ └── NewsEventPublisher.java │ │ │ └── properties │ │ │ └── AwsProperties.java │ └── resources │ │ ├── application.properties │ │ └── banner.txt │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── dynamodblambdafunction │ └── DynamodbLambdaFunctionApplicationTests.java ├── init-localstack.sh ├── mvnw ├── mvnw.cmd ├── news-consumer ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── newsconsumer │ │ │ ├── NewsConsumerApplication.java │ │ │ ├── client │ │ │ ├── NewsProducerClient.java │ │ │ ├── NewsProducerClientConfig.java │ │ │ └── NewsResponse.java │ │ │ ├── config │ │ │ └── WebSocketConfig.java │ │ │ └── news │ │ │ ├── NewsController.java │ │ │ ├── NewsEvent.java │ │ │ └── NewsEventListener.java │ └── resources │ │ ├── application.properties │ │ ├── banner.txt │ │ ├── static │ │ └── app.js │ │ └── templates │ │ └── news.html │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── newsconsumer │ └── NewsConsumerApplicationTests.java ├── news-producer ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── newsproducer │ │ │ ├── NewsProducerApplication.java │ │ │ ├── config │ │ │ ├── ErrorAttributesConfig.java │ │ │ └── SwaggerConfig.java │ │ │ └── news │ │ │ ├── NewsController.java │ │ │ ├── NewsService.java │ │ │ ├── RandomNewsGenerator.java │ │ │ ├── dto │ │ │ └── CreateNewsRequest.java │ │ │ ├── exception │ │ │ └── NewsNotFoundException.java │ │ │ └── model │ │ │ └── News.java │ └── resources │ │ ├── application.properties │ │ └── banner.txt │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── newsproducer │ └── NewsProducerApplicationTests.java ├── package-dynamodb-lambda-function-jar.sh ├── pom.xml └── remove-docker-images.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | /mvnw text eol=lf 2 | *.cmd text eol=crlf 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ivangfr 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | shared/ 3 | 4 | target/ 5 | !.mvn/wrapper/maven-wrapper.jar 6 | !**/src/main/**/target/ 7 | !**/src/test/**/target/ 8 | 9 | ### STS ### 10 | .apt_generated 11 | .classpath 12 | .factorypath 13 | .project 14 | .settings 15 | .springBeans 16 | .sts4-cache 17 | 18 | ### IntelliJ IDEA ### 19 | .idea 20 | *.iws 21 | *.iml 22 | *.ipr 23 | 24 | ### NetBeans ### 25 | /nbproject/private/ 26 | /nbbuild/ 27 | /dist/ 28 | /nbdist/ 29 | /.nb-gradle/ 30 | build/ 31 | !**/src/main/**/build/ 32 | !**/src/test/**/build/ 33 | 34 | ### VS Code ### 35 | .vscode/ 36 | 37 | ### MAC OS ### 38 | *.DS_Store 39 | -------------------------------------------------------------------------------- /.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 | # springboot-aws-localstack-dynamodb-lambda-sns-sqs 2 | 3 | In this project, we will use [`LocalStack`](https://localstack.cloud/) to locally simulate some services provided by [`AWS Cloud`](https://aws.amazon.com/) such as: [`DynamoDB`](https://aws.amazon.com/dynamodb/), [`Lambda`](https://aws.amazon.com/lambda/), [`SNS`](https://aws.amazon.com/sns/) and [`SQS`](https://aws.amazon.com/sqs/). Additionally, to simplify the use of AWS managed services, we will use [`Spring Cloud AWS`](https://spring.io/projects/spring-cloud-aws). 4 | 5 | > **Note**: Also, take a look at the [`springboot-aws-localstack-opensearch-s3-secretsmanager`](https://github.com/ivangfr/springboot-aws-localstack-opensearch-s3-secretsmanager) repository. There, we have implemented two Spring Boot applications for indexing and searching movies. We also used LocalStack to simulate AWS Cloud services locally, such as [`OpenSearch`](https://aws.amazon.com/opensearch-service/), [`S3`](https://aws.amazon.com/s3/), and [`Secrets Manager`](https://aws.amazon.com/secrets-manager/). 6 | 7 | ## Proof-of-Concepts & Articles 8 | 9 | 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. 10 | 11 | ## Additional Readings 12 | 13 | - \[**Medium**\] [**Spring Boot apps to trigger and consume DynamoDB News table updates using AWS Lambda, SNS and SQS**](https://medium.com/@ivangfr/spring-boot-apps-to-trigger-and-consume-dynamodb-news-table-updates-using-aws-lambda-sns-and-sqs-957570cf9a3a) 14 | - \[**Medium**\] [**Spring Boot Apps for Movie Indexing/Search with AWS OpenSearch, S3 and Secrets Manager**](https://medium.com/@ivangfr/spring-boot-apps-for-movie-indexing-search-with-aws-opensearch-s3-and-secrets-manager-a95ad0697e51) 15 | - \[**Medium**\] [**Implementing a Spring Boot App using AWS DynamoDB as database**](https://medium.com/@ivangfr/implementing-a-spring-boot-app-using-aws-dynamodb-as-database-5dbf8b7fc924) 16 | - \[**Medium**\] [**Implementing a Spring Boot App that uses AWS Secrets Manager to store its MongoDB credentials**](https://medium.com/@ivangfr/implementing-a-spring-boot-app-that-uses-aws-secrets-manager-to-store-its-mongodb-credentials-f805a4c74d9a) 17 | - \[**Medium**\] [**Implementing a Serverless AWS Lambda with Spring Cloud Function & AWS Adapter**](https://medium.com/@ivangfr/implementing-a-serverless-aws-lambda-with-spring-cloud-function-aws-adapter-05fd6d48ba45) 18 | - \[**Medium**\] [**Using AWS SNS and SQS to stream Alerts from a Spring Boot producer to consumers**](https://medium.com/@ivangfr/using-aws-sns-and-sqs-to-stream-alerts-from-a-spring-boot-producer-to-consumers-0b0a974e40fc) 19 | 20 | ## Project Diagram 21 | 22 | ![project-diagram](documentation/project-diagram.jpeg) 23 | 24 | ## Applications 25 | 26 | - ### news-producer 27 | 28 | [`Spring Boot`](https://docs.spring.io/spring-boot/index.html) Java Web application that exposes a REST API to manage news. It uses DynamoDB as database. 29 | 30 | It has the following endpoints: 31 | ```text 32 | GET /api/news 33 | GET /api/news/{id} 34 | POST /api/news {"title": "..."} 35 | POST /api/news/randomly 36 | DELETE /api/news/{id} 37 | ``` 38 | 39 | - ### dynamodb-lambda-function 40 | 41 | [`Spring Cloud Function`](https://spring.io/projects/spring-cloud-function) application that uses [`AWS Adapter`](https://docs.spring.io/spring-cloud-function/reference/adapters/aws-intro.html) to convert it to a form that can run in `AWS Lambda`. 42 | 43 | `dynamodb-lambda-function` listens to events emitted by an event-source created to monitor changes in `DynamoDB` news table. Once it receives an event, it processes it and publishes a news event to an `SNS` topic. Later, `SNS` publishes the news event to a `SQS` queue. 44 | 45 | - ### news-consumer 46 | 47 | `Spring Boot` Java Web application that polls the news events that are queued in a `SQS` queue. 48 | 49 | ## Prerequisites 50 | 51 | - [`Java 21`](https://www.oracle.com/java/technologies/downloads/#java21) or higher; 52 | - A containerization tool (e.g., [`Docker`](https://www.docker.com), [`Podman`](https://podman.io), etc.) 53 | 54 | ## Package dynamodb-lambda-function jar 55 | 56 | - In a terminal, make sure you inside the `springboot-aws-localstack-dynamodb-lambda-sns-sqs` root folder: 57 | 58 | - Run the following script: 59 | ```bash 60 | ./package-dynamodb-lambda-function-jar.sh 61 | ``` 62 | When `Maven` packaging finishes, the jar file generated in `dynamodb-lambda-function/target` folder is copied to `dynamodb-lambda-function/shared` folder. 63 | 64 | ## Start and Initialize LocalStack 65 | 66 | - In a terminal, make sure you are in inside the `springboot-aws-localstack-dynamodb-lambda-sns-sqs` root folder: 67 | 68 | - Start `LocalStack` Docker container: 69 | ```bash 70 | DEBUG=1 docker compose up -d 71 | ``` 72 | 73 | - \[Optional\] Debug logs are enabled so that we have more insights about what is happening. To monitor `localstack` Docker container logs, run the command below: 74 | ```bash 75 | docker logs localstack 76 | ``` 77 | 78 | - Initialize `LocalStack` by running the following script: 79 | ```bash 80 | ./init-localstack.sh 81 | ``` 82 | The script will create: 83 | - create `news-topic` in `SNS`; 84 | - create `news-consumer-queue` in `SQS`; 85 | - subscribe `news-consumer-queue` to `news-topic`; 86 | - create `news` table in `DynamoDB`; 87 | - create `ProcessDynamoDBEvent` Lambda function; 88 | - create an `event-source-mapping` to connect `DynamoDB` to `ProcessDynamoDBEvent` Lambda function. 89 | 90 | ## Running applications with Maven 91 | 92 | - **news-producer** 93 | 94 | In a terminal and, inside the `springboot-aws-localstack-dynamodb-lambda-sns-sqs` root folder, run the following command: 95 | ```bash 96 | export AWS_REGION=eu-west-1 && export AWS_ACCESS_KEY_ID=key && export AWS_SECRET_ACCESS_KEY=secret && \ 97 | ./mvnw clean spring-boot:run --projects news-producer 98 | ``` 99 | 100 | - **news-consumer** 101 | 102 | In another terminal and, inside the `springboot-aws-localstack-dynamodb-lambda-sns-sqs` root folder, run the command below: 103 | ```bash 104 | export AWS_REGION=eu-west-1 && export AWS_ACCESS_KEY_ID=key && export AWS_SECRET_ACCESS_KEY=secret && \ 105 | ./mvnw clean spring-boot:run --projects news-consumer 106 | ``` 107 | 108 | ## Running applications as Docker container 109 | 110 | - ### Build Docker images 111 | 112 | In a terminal and, inside the `springboot-aws-localstack-dynamodb-lambda-sns-sqs` root folder, run the following script: 113 | ```bash 114 | ./build-docker-images.sh 115 | ``` 116 | 117 | - ### Run Docker containers 118 | 119 | - **news-producer** 120 | 121 | In a terminal, run the following command: 122 | ```bash 123 | docker run --rm --name news-producer -p 9080:9080 \ 124 | -e AWS_REGION=eu-west-1 -e AWS_ACCESS_KEY_ID=key -e AWS_SECRET_ACCESS_KEY=secret \ 125 | --network=springboot-aws-localstack-dynamodb-lambda-sns-sqs_default \ 126 | ivanfranchin/news-producer:1.0.0 127 | ``` 128 | 129 | - **news-consumer** 130 | 131 | In a new terminal, run the command below: 132 | ```bash 133 | docker run --rm --name news-consumer -p 9081:9081 \ 134 | -e AWS_REGION=eu-west-1 -e AWS_ACCESS_KEY_ID=key -e AWS_SECRET_ACCESS_KEY=secret \ 135 | -e NEWS_PRODUCER_URL=http://news-producer:9080 \ 136 | --network=springboot-aws-localstack-dynamodb-lambda-sns-sqs_default \ 137 | ivanfranchin/news-consumer:1.0.0 138 | ``` 139 | 140 | ## Application URL 141 | 142 | | Application | Type | URL | 143 | |-----------------|---------|---------------------------------------| 144 | | `news-producer` | Swagger | http://localhost:9080/swagger-ui.html | 145 | | `news-consumer` | UI | http://localhost:9081 | 146 | 147 | ## Playing around 148 | 149 | - **Creating news** 150 | 151 | - In a terminal, run the following command: 152 | ```bash 153 | curl -i -X POST http://localhost:9080/api/news \ 154 | -H 'Content-Type: application/json' \ 155 | -d '{"title": "Palmeiras is three-time champion of the Copa Libertadores da América"}' 156 | ``` 157 | 158 | or to create news randomly: 159 | ```bash 160 | curl -i -X POST http://localhost:9080/api/news/randomly 161 | ``` 162 | 163 | - In `news-consumer` UI, the news should be displayed. 164 | 165 | - **Deleting news** 166 | 167 | - In a terminal, run the following command: 168 | ```bash 169 | curl -i -X DELETE http://localhost:9080/api/news/ 170 | ``` 171 | 172 | - In `news-consumer` UI, the news should be removed. 173 | 174 | ## Demo 175 | 176 | In the `GIF` below, we use `news-producer` Swagger UI to create one random news. Then, we delete the news created previously. Finally, we create more two more news randomly. 177 | 178 | ![demo](documentation/demo.gif) 179 | 180 | ## Shutdown 181 | 182 | - To stop the applications, go to the terminal where they are running and press `Ctrl+C`; 183 | - To stop and remove Docker Compose containers, network, and volumes, go to a terminal and, inside the `springboot-aws-localstack-dynamodb-lambda-sns-sqs` root folder, run the following command: 184 | ```bash 185 | docker compose down -v 186 | ``` 187 | 188 | ## Cleanup 189 | 190 | To remove the Docker images created by this project, go to a terminal and, inside the `springboot-aws-localstack-dynamodb-lambda-sns-sqs` root folder, run the script below: 191 | ```bash 192 | ./remove-docker-images.sh 193 | ``` 194 | -------------------------------------------------------------------------------- /build-docker-images.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DOCKER_IMAGE_PREFIX="ivanfranchin" 4 | APP_VERSION="1.0.0" 5 | 6 | NEWS_PRODUCER_APP_NAME="news-producer" 7 | NEWS_CONSUMER_APP_NAME="news-consumer" 8 | NEWS_PRODUCER_DOCKER_IMAGE_NAME="${DOCKER_IMAGE_PREFIX}/${NEWS_PRODUCER_APP_NAME}:${APP_VERSION}" 9 | NEWS_CONSUMER_DOCKER_IMAGE_NAME="${DOCKER_IMAGE_PREFIX}/${NEWS_CONSUMER_APP_NAME}:${APP_VERSION}" 10 | 11 | SKIP_TESTS="true" 12 | 13 | ./mvnw clean spring-boot:build-image \ 14 | --projects "$NEWS_PRODUCER_APP_NAME" \ 15 | -DskipTests="$SKIP_TESTS" \ 16 | -Dspring-boot.build-image.imageName="$NEWS_PRODUCER_DOCKER_IMAGE_NAME" 17 | 18 | ./mvnw clean spring-boot:build-image \ 19 | --projects "$NEWS_CONSUMER_APP_NAME" \ 20 | -DskipTests="$SKIP_TESTS" \ 21 | -Dspring-boot.build-image.imageName="$NEWS_CONSUMER_DOCKER_IMAGE_NAME" 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | localstack: 4 | container_name: 'localstack' 5 | image: 'localstack/localstack:4.2.0' 6 | ports: 7 | - '127.0.0.1:4510-4559:4510-4559' # external service port range 8 | - '127.0.0.1:4566:4566' # LocalStack Edge Proxy 9 | environment: 10 | - 'LOCALSTACK_HOSTNAME=localhost.localstack.cloud' # set this env var to expose localstack to other containers 11 | - 'AWS_ACCESS_KEY_ID=key' 12 | - 'AWS_SECRET_ACCESS_KEY=secret' 13 | - 'AWS_DEFAULT_REGION=eu-west-1' 14 | - 'SERVICES=dynamodb,sns,sqs,lambda' 15 | # --- 16 | - 'DEBUG=${DEBUG-}' 17 | volumes: 18 | - '$PWD/tmp/localstack:/var/lib/localstack' 19 | - '$PWD/dynamodb-lambda-function/shared:/shared' 20 | - '/var/run/docker.sock:/var/run/docker.sock' 21 | networks: 22 | default: 23 | aliases: 24 | - 'localhost.localstack.cloud' # set this network aliases to expose localstack to other containers 25 | -------------------------------------------------------------------------------- /documentation/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-aws-localstack-dynamodb-lambda-sns-sqs/14a0ecf81be9a8512f93487d7f4a183dd63a139b/documentation/demo.gif -------------------------------------------------------------------------------- /documentation/project-diagram.excalidraw: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "rectangle", 8 | "version": 1755, 9 | "versionNonce": 1277585846, 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": -112.87300109863281, 19 | "y": 89.15087106639828, 20 | "strokeColor": "#000000", 21 | "backgroundColor": "#fd7e14", 22 | "width": 209.18356323242188, 23 | "height": 99.67071533203125, 24 | "seed": 584921962, 25 | "groupIds": [], 26 | "roundness": { 27 | "type": 3 28 | }, 29 | "boundElements": [ 30 | { 31 | "type": "text", 32 | "id": "2X2Ld8TDu-NPJPkdyJl5g" 33 | }, 34 | { 35 | "id": "WvRDE7o2WCeeRfaHm7o38", 36 | "type": "arrow" 37 | }, 38 | { 39 | "id": "turVDsRIFdQK33RASnQwn", 40 | "type": "arrow" 41 | } 42 | ], 43 | "updated": 1682459400859, 44 | "link": null, 45 | "locked": false 46 | }, 47 | { 48 | "type": "text", 49 | "version": 1759, 50 | "versionNonce": 1058452650, 51 | "isDeleted": false, 52 | "id": "2X2Ld8TDu-NPJPkdyJl5g", 53 | "fillStyle": "hachure", 54 | "strokeWidth": 1, 55 | "strokeStyle": "solid", 56 | "roughness": 1, 57 | "opacity": 100, 58 | "angle": 0, 59 | "x": -103.77517700195312, 60 | "y": 122.1862287324139, 61 | "strokeColor": "#000000", 62 | "backgroundColor": "transparent", 63 | "width": 190.9879150390625, 64 | "height": 33.6, 65 | "seed": 200323745, 66 | "groupIds": [], 67 | "roundness": null, 68 | "boundElements": [], 69 | "updated": 1682459400859, 70 | "link": null, 71 | "locked": false, 72 | "fontSize": 28, 73 | "fontFamily": 1, 74 | "text": "news-consumer", 75 | "textAlign": "center", 76 | "verticalAlign": "middle", 77 | "containerId": "htH4DvpAlw_lK0WCfkn_y", 78 | "originalText": "news-consumer", 79 | "lineHeight": 1.2, 80 | "baseline": 24 81 | }, 82 | { 83 | "type": "rectangle", 84 | "version": 1449, 85 | "versionNonce": 1784398582, 86 | "isDeleted": false, 87 | "id": "NKmNZxYxWMCKh3prRiPwX", 88 | "fillStyle": "hachure", 89 | "strokeWidth": 1, 90 | "strokeStyle": "solid", 91 | "roughness": 1, 92 | "opacity": 100, 93 | "angle": 0, 94 | "x": -110.85682457451446, 95 | "y": -143.58913050683992, 96 | "strokeColor": "#000000", 97 | "backgroundColor": "#4c6ef5", 98 | "width": 209.18356323242188, 99 | "height": 99.67071533203125, 100 | "seed": 1484207658, 101 | "groupIds": [], 102 | "roundness": { 103 | "type": 3 104 | }, 105 | "boundElements": [ 106 | { 107 | "type": "text", 108 | "id": "GrVT2PZuYu1cRv3sXwFoI" 109 | }, 110 | { 111 | "id": "Jqh5ZDuGXIPEPs4oKk13F", 112 | "type": "arrow" 113 | }, 114 | { 115 | "id": "s75NFoH7QND6gLrkoAHQN", 116 | "type": "arrow" 117 | }, 118 | { 119 | "id": "turVDsRIFdQK33RASnQwn", 120 | "type": "arrow" 121 | } 122 | ], 123 | "updated": 1682459400859, 124 | "link": null, 125 | "locked": false 126 | }, 127 | { 128 | "type": "text", 129 | "version": 338, 130 | "versionNonce": 1282314090, 131 | "isDeleted": false, 132 | "id": "GrVT2PZuYu1cRv3sXwFoI", 133 | "fillStyle": "hachure", 134 | "strokeWidth": 1, 135 | "strokeStyle": "solid", 136 | "roughness": 0, 137 | "opacity": 100, 138 | "angle": 0, 139 | "x": -99.6449944353543, 140 | "y": -110.55377284082431, 141 | "strokeColor": "#000000", 142 | "backgroundColor": "transparent", 143 | "width": 186.75990295410156, 144 | "height": 33.6, 145 | "seed": 294979727, 146 | "groupIds": [], 147 | "roundness": null, 148 | "boundElements": [], 149 | "updated": 1682459400859, 150 | "link": null, 151 | "locked": false, 152 | "fontSize": 28, 153 | "fontFamily": 1, 154 | "text": "news-producer", 155 | "textAlign": "center", 156 | "verticalAlign": "middle", 157 | "containerId": "NKmNZxYxWMCKh3prRiPwX", 158 | "originalText": "news-producer", 159 | "lineHeight": 1.2, 160 | "baseline": 24 161 | }, 162 | { 163 | "type": "ellipse", 164 | "version": 1684, 165 | "versionNonce": 1662252086, 166 | "isDeleted": false, 167 | "id": "zYllgBlgP7S7-phqNnnEr", 168 | "fillStyle": "hachure", 169 | "strokeWidth": 2, 170 | "strokeStyle": "solid", 171 | "roughness": 1, 172 | "opacity": 100, 173 | "angle": 0, 174 | "x": 40.10388404365, 175 | "y": -285.04171011035953, 176 | "strokeColor": "#000000", 177 | "backgroundColor": "transparent", 178 | "width": 26.930389404296875, 179 | "height": 27.545562744140625, 180 | "seed": 960684266, 181 | "groupIds": [ 182 | "4D1ojplACrlVIaNZ7P0FH" 183 | ], 184 | "roundness": { 185 | "type": 2 186 | }, 187 | "boundElements": [], 188 | "updated": 1682459400859, 189 | "link": null, 190 | "locked": false 191 | }, 192 | { 193 | "type": "line", 194 | "version": 1703, 195 | "versionNonce": 2138903082, 196 | "isDeleted": false, 197 | "id": "iW_3iMfYwsgECYYtnEK33", 198 | "fillStyle": "hachure", 199 | "strokeWidth": 2, 200 | "strokeStyle": "solid", 201 | "roughness": 1, 202 | "opacity": 100, 203 | "angle": 0, 204 | "x": 51.54171973700892, 205 | "y": -257.0410692412189, 206 | "strokeColor": "#000000", 207 | "backgroundColor": "transparent", 208 | "width": 0.473419189453125, 209 | "height": 40.3687744140625, 210 | "seed": 1087058858, 211 | "groupIds": [ 212 | "4D1ojplACrlVIaNZ7P0FH" 213 | ], 214 | "roundness": { 215 | "type": 2 216 | }, 217 | "boundElements": [], 218 | "updated": 1682459400859, 219 | "link": null, 220 | "locked": false, 221 | "startBinding": null, 222 | "endBinding": null, 223 | "lastCommittedPoint": null, 224 | "startArrowhead": null, 225 | "endArrowhead": null, 226 | "points": [ 227 | [ 228 | 0, 229 | 0 230 | ], 231 | [ 232 | -0.473419189453125, 233 | 40.3687744140625 234 | ] 235 | ] 236 | }, 237 | { 238 | "type": "line", 239 | "version": 1654, 240 | "versionNonce": 271842678, 241 | "isDeleted": false, 242 | "id": "8JTNvN86yjteIVYunsqxA", 243 | "fillStyle": "hachure", 244 | "strokeWidth": 2, 245 | "strokeStyle": "solid", 246 | "roughness": 1, 247 | "opacity": 100, 248 | "angle": 0, 249 | "x": 51.24621802802454, 250 | "y": -215.23741933887516, 251 | "strokeColor": "#000000", 252 | "backgroundColor": "transparent", 253 | "width": 17.21380615234375, 254 | "height": 33.91400146484375, 255 | "seed": 1094397546, 256 | "groupIds": [ 257 | "4D1ojplACrlVIaNZ7P0FH" 258 | ], 259 | "roundness": { 260 | "type": 2 261 | }, 262 | "boundElements": [], 263 | "updated": 1682459400859, 264 | "link": null, 265 | "locked": false, 266 | "startBinding": null, 267 | "endBinding": null, 268 | "lastCommittedPoint": null, 269 | "startArrowhead": null, 270 | "endArrowhead": null, 271 | "points": [ 272 | [ 273 | 0, 274 | 0 275 | ], 276 | [ 277 | -17.21380615234375, 278 | 33.91400146484375 279 | ] 280 | ] 281 | }, 282 | { 283 | "type": "line", 284 | "version": 1673, 285 | "versionNonce": 992820458, 286 | "isDeleted": false, 287 | "id": "hMliKg9HCYu-lEplUg1Y6", 288 | "fillStyle": "hachure", 289 | "strokeWidth": 2, 290 | "strokeStyle": "solid", 291 | "roughness": 1, 292 | "opacity": 100, 293 | "angle": 0, 294 | "x": 51.36206275458704, 295 | "y": -215.1172411162189, 296 | "strokeColor": "#000000", 297 | "backgroundColor": "transparent", 298 | "width": 12.9422607421875, 299 | "height": 35.16510009765625, 300 | "seed": 257806634, 301 | "groupIds": [ 302 | "4D1ojplACrlVIaNZ7P0FH" 303 | ], 304 | "roundness": { 305 | "type": 2 306 | }, 307 | "boundElements": [], 308 | "updated": 1682459400859, 309 | "link": null, 310 | "locked": false, 311 | "startBinding": null, 312 | "endBinding": null, 313 | "lastCommittedPoint": null, 314 | "startArrowhead": null, 315 | "endArrowhead": null, 316 | "points": [ 317 | [ 318 | 0, 319 | 0 320 | ], 321 | [ 322 | 12.9422607421875, 323 | 35.16510009765625 324 | ] 325 | ] 326 | }, 327 | { 328 | "type": "line", 329 | "version": 1689, 330 | "versionNonce": 1191239350, 331 | "isDeleted": false, 332 | "id": "pnA0DA25tJxDKgaSQnlkY", 333 | "fillStyle": "hachure", 334 | "strokeWidth": 2, 335 | "strokeStyle": "solid", 336 | "roughness": 1, 337 | "opacity": 100, 338 | "angle": 0, 339 | "x": 52.55789405341517, 340 | "y": -239.54308340137516, 341 | "strokeColor": "#000000", 342 | "backgroundColor": "transparent", 343 | "width": 29.445220947265625, 344 | "height": 20.990234375, 345 | "seed": 782113770, 346 | "groupIds": [ 347 | "4D1ojplACrlVIaNZ7P0FH" 348 | ], 349 | "roundness": { 350 | "type": 2 351 | }, 352 | "boundElements": [], 353 | "updated": 1682459400859, 354 | "link": null, 355 | "locked": false, 356 | "startBinding": null, 357 | "endBinding": null, 358 | "lastCommittedPoint": null, 359 | "startArrowhead": null, 360 | "endArrowhead": null, 361 | "points": [ 362 | [ 363 | 0, 364 | 0 365 | ], 366 | [ 367 | 29.445220947265625, 368 | -20.990234375 369 | ] 370 | ] 371 | }, 372 | { 373 | "type": "line", 374 | "version": 1728, 375 | "versionNonce": 1292505002, 376 | "isDeleted": false, 377 | "id": "HhvpXqS4JXS1iu_1aiVep", 378 | "fillStyle": "hachure", 379 | "strokeWidth": 2, 380 | "strokeStyle": "solid", 381 | "roughness": 1, 382 | "opacity": 100, 383 | "angle": 0, 384 | "x": 51.66699439521204, 385 | "y": -240.2732469755939, 386 | "strokeColor": "#000000", 387 | "backgroundColor": "transparent", 388 | "width": 25.4169921875, 389 | "height": 9.85821533203125, 390 | "seed": 2097614506, 391 | "groupIds": [ 392 | "4D1ojplACrlVIaNZ7P0FH" 393 | ], 394 | "roundness": { 395 | "type": 2 396 | }, 397 | "boundElements": [], 398 | "updated": 1682459400859, 399 | "link": null, 400 | "locked": false, 401 | "startBinding": null, 402 | "endBinding": null, 403 | "lastCommittedPoint": null, 404 | "startArrowhead": null, 405 | "endArrowhead": null, 406 | "points": [ 407 | [ 408 | 0, 409 | 0 410 | ], 411 | [ 412 | -25.4169921875, 413 | -9.85821533203125 414 | ] 415 | ] 416 | }, 417 | { 418 | "type": "text", 419 | "version": 1754, 420 | "versionNonce": 1814009846, 421 | "isDeleted": false, 422 | "id": "T0m-48lm7wA_Uw99BXSmk", 423 | "fillStyle": "hachure", 424 | "strokeWidth": 2, 425 | "strokeStyle": "solid", 426 | "roughness": 1, 427 | "opacity": 100, 428 | "angle": 0, 429 | "x": 29.458101572946873, 430 | "y": -316.98714468067203, 431 | "strokeColor": "#000000", 432 | "backgroundColor": "transparent", 433 | "width": 44.679962158203125, 434 | "height": 24, 435 | "seed": 1598269802, 436 | "groupIds": [ 437 | "4D1ojplACrlVIaNZ7P0FH" 438 | ], 439 | "roundness": null, 440 | "boundElements": [], 441 | "updated": 1682459400859, 442 | "link": null, 443 | "locked": false, 444 | "fontSize": 20, 445 | "fontFamily": 1, 446 | "text": "User", 447 | "textAlign": "left", 448 | "verticalAlign": "top", 449 | "containerId": null, 450 | "originalText": "User", 451 | "lineHeight": 1.2, 452 | "baseline": 17 453 | }, 454 | { 455 | "type": "rectangle", 456 | "version": 385, 457 | "versionNonce": 423529066, 458 | "isDeleted": false, 459 | "id": "0tFsB5XswcFk43y4cu2nN", 460 | "fillStyle": "hachure", 461 | "strokeWidth": 1, 462 | "strokeStyle": "solid", 463 | "roughness": 1, 464 | "opacity": 100, 465 | "angle": 0, 466 | "x": 146.97243187964068, 467 | "y": -243.07421133106266, 468 | "strokeColor": "#000000", 469 | "backgroundColor": "#ced4da", 470 | "width": 630.9326782226562, 471 | "height": 462.5319061279297, 472 | "seed": 1822354474, 473 | "groupIds": [], 474 | "roundness": { 475 | "type": 3 476 | }, 477 | "boundElements": [], 478 | "updated": 1682459400859, 479 | "link": null, 480 | "locked": false 481 | }, 482 | { 483 | "type": "text", 484 | "version": 246, 485 | "versionNonce": 127215926, 486 | "isDeleted": false, 487 | "id": "kx1ruLDwJgOJo3ggUxasx", 488 | "fillStyle": "hachure", 489 | "strokeWidth": 1, 490 | "strokeStyle": "solid", 491 | "roughness": 1, 492 | "opacity": 100, 493 | "angle": 0, 494 | "x": 338.6579787546407, 495 | "y": -224.73615285938297, 496 | "strokeColor": "#000000", 497 | "backgroundColor": "#ced4da", 498 | "width": 247.37991333007812, 499 | "height": 33.6, 500 | "seed": 453236458, 501 | "groupIds": [], 502 | "roundness": null, 503 | "boundElements": [], 504 | "updated": 1682459400859, 505 | "link": null, 506 | "locked": false, 507 | "fontSize": 28, 508 | "fontFamily": 1, 509 | "text": "AWS - LocalStack", 510 | "textAlign": "left", 511 | "verticalAlign": "top", 512 | "containerId": null, 513 | "originalText": "AWS - LocalStack", 514 | "lineHeight": 1.2, 515 | "baseline": 24 516 | }, 517 | { 518 | "type": "text", 519 | "version": 206, 520 | "versionNonce": 1184214314, 521 | "isDeleted": false, 522 | "id": "f13FJlYghROUhu-phVfdC", 523 | "fillStyle": "hachure", 524 | "strokeWidth": 1, 525 | "strokeStyle": "solid", 526 | "roughness": 1, 527 | "opacity": 100, 528 | "angle": 0, 529 | "x": 278.2192580515157, 530 | "y": -182.43514577930486, 531 | "strokeColor": "#000000", 532 | "backgroundColor": "#ced4da", 533 | "width": 371.2196350097656, 534 | "height": 24, 535 | "seed": 1794992554, 536 | "groupIds": [], 537 | "roundness": null, 538 | "boundElements": [], 539 | "updated": 1682459400859, 540 | "link": null, 541 | "locked": false, 542 | "fontSize": 20, 543 | "fontFamily": 1, 544 | "text": "http://localhost.localstack.cloud:4566", 545 | "textAlign": "left", 546 | "verticalAlign": "top", 547 | "containerId": null, 548 | "originalText": "http://localhost.localstack.cloud:4566", 549 | "lineHeight": 1.2, 550 | "baseline": 17 551 | }, 552 | { 553 | "type": "rectangle", 554 | "version": 1531, 555 | "versionNonce": 1489408630, 556 | "isDeleted": false, 557 | "id": "5dfGFWAlSm2IIFJO-UQC_", 558 | "fillStyle": "hachure", 559 | "strokeWidth": 1, 560 | "strokeStyle": "solid", 561 | "roughness": 1, 562 | "opacity": 100, 563 | "angle": 0, 564 | "x": 502.4648482614766, 565 | "y": -128.13014613183992, 566 | "strokeColor": "#000000", 567 | "backgroundColor": "#fab005", 568 | "width": 246, 569 | "height": 100, 570 | "seed": 330736746, 571 | "groupIds": [], 572 | "roundness": { 573 | "type": 3 574 | }, 575 | "boundElements": [ 576 | { 577 | "type": "text", 578 | "id": "jMg6gt50ITtLzhT8jmb-4" 579 | }, 580 | { 581 | "id": "LcbBa85LEVGMrzXi9yzNE", 582 | "type": "arrow" 583 | }, 584 | { 585 | "id": "E7iDW5WcVMTFtLbjsePxR", 586 | "type": "arrow" 587 | } 588 | ], 589 | "updated": 1682459400859, 590 | "link": null, 591 | "locked": false 592 | }, 593 | { 594 | "type": "text", 595 | "version": 452, 596 | "versionNonce": 1786410986, 597 | "isDeleted": false, 598 | "id": "jMg6gt50ITtLzhT8jmb-4", 599 | "fillStyle": "hachure", 600 | "strokeWidth": 1, 601 | "strokeStyle": "solid", 602 | "roughness": 0, 603 | "opacity": 100, 604 | "angle": 0, 605 | "x": 511.3088881784688, 606 | "y": -111.73014613183992, 607 | "strokeColor": "#000000", 608 | "backgroundColor": "transparent", 609 | "width": 228.31192016601562, 610 | "height": 67.2, 611 | "seed": 416749719, 612 | "groupIds": [], 613 | "roundness": null, 614 | "boundElements": [], 615 | "updated": 1682459400859, 616 | "link": null, 617 | "locked": false, 618 | "fontSize": 28, 619 | "fontFamily": 1, 620 | "text": "dynamodb-lambda\n-function", 621 | "textAlign": "center", 622 | "verticalAlign": "middle", 623 | "containerId": "5dfGFWAlSm2IIFJO-UQC_", 624 | "originalText": "dynamodb-lambda-function", 625 | "lineHeight": 1.2, 626 | "baseline": 58 627 | }, 628 | { 629 | "type": "rectangle", 630 | "version": 1529, 631 | "versionNonce": 1786303414, 632 | "isDeleted": false, 633 | "id": "fVTcwYGKM18HqDlRP3mv9", 634 | "fillStyle": "hachure", 635 | "strokeWidth": 1, 636 | "strokeStyle": "solid", 637 | "roughness": 1, 638 | "opacity": 100, 639 | "angle": 0, 640 | "x": 207.28262780249224, 641 | "y": -128.2558480361368, 642 | "strokeColor": "#000000", 643 | "backgroundColor": "#15aabf", 644 | "width": 215, 645 | "height": 100, 646 | "seed": 788170538, 647 | "groupIds": [ 648 | "wrUupyRDJMZCrJT-EIkr2" 649 | ], 650 | "roundness": { 651 | "type": 3 652 | }, 653 | "boundElements": [ 654 | { 655 | "type": "text", 656 | "id": "iN_wXnFZKTqNFsj6OTpWK" 657 | } 658 | ], 659 | "updated": 1682459400859, 660 | "link": null, 661 | "locked": false 662 | }, 663 | { 664 | "type": "text", 665 | "version": 458, 666 | "versionNonce": 888662698, 667 | "isDeleted": false, 668 | "id": "iN_wXnFZKTqNFsj6OTpWK", 669 | "fillStyle": "hachure", 670 | "strokeWidth": 1, 671 | "strokeStyle": "solid", 672 | "roughness": 1, 673 | "opacity": 100, 674 | "angle": 0, 675 | "x": 243.70465539038287, 676 | "y": -111.85584803613679, 677 | "strokeColor": "#000000", 678 | "backgroundColor": "#ced4da", 679 | "width": 142.15594482421875, 680 | "height": 67.2, 681 | "seed": 1697911274, 682 | "groupIds": [ 683 | "wrUupyRDJMZCrJT-EIkr2" 684 | ], 685 | "roundness": null, 686 | "boundElements": [], 687 | "updated": 1682459400859, 688 | "link": null, 689 | "locked": false, 690 | "fontSize": 28, 691 | "fontFamily": 1, 692 | "text": "DynamoDB\n", 693 | "textAlign": "center", 694 | "verticalAlign": "middle", 695 | "containerId": "fVTcwYGKM18HqDlRP3mv9", 696 | "originalText": "DynamoDB\n", 697 | "lineHeight": 1.2, 698 | "baseline": 58 699 | }, 700 | { 701 | "type": "rectangle", 702 | "version": 118, 703 | "versionNonce": 651226358, 704 | "isDeleted": false, 705 | "id": "GtcbZw9umcTWrEQ2Ma_5A", 706 | "fillStyle": "solid", 707 | "strokeWidth": 1, 708 | "strokeStyle": "solid", 709 | "roughness": 1, 710 | "opacity": 100, 711 | "angle": 0, 712 | "x": 272.69783471167193, 713 | "y": -69.6784554580118, 714 | "strokeColor": "#000000", 715 | "backgroundColor": "#ced4da", 716 | "width": 88.93072509765625, 717 | "height": 34, 718 | "seed": 426656938, 719 | "groupIds": [ 720 | "wrUupyRDJMZCrJT-EIkr2" 721 | ], 722 | "roundness": { 723 | "type": 3 724 | }, 725 | "boundElements": [ 726 | { 727 | "type": "text", 728 | "id": "bbim4jo_rB7EJ3PsnnMux" 729 | }, 730 | { 731 | "id": "Jqh5ZDuGXIPEPs4oKk13F", 732 | "type": "arrow" 733 | }, 734 | { 735 | "id": "LcbBa85LEVGMrzXi9yzNE", 736 | "type": "arrow" 737 | } 738 | ], 739 | "updated": 1682459400859, 740 | "link": null, 741 | "locked": false 742 | }, 743 | { 744 | "type": "text", 745 | "version": 45, 746 | "versionNonce": 481887594, 747 | "isDeleted": false, 748 | "id": "bbim4jo_rB7EJ3PsnnMux", 749 | "fillStyle": "hachure", 750 | "strokeWidth": 1, 751 | "strokeStyle": "solid", 752 | "roughness": 1, 753 | "opacity": 100, 754 | "angle": 0, 755 | "x": 295.5032164865743, 756 | "y": -64.6784554580118, 757 | "strokeColor": "#000000", 758 | "backgroundColor": "#ced4da", 759 | "width": 43.31996154785156, 760 | "height": 24, 761 | "seed": 357834602, 762 | "groupIds": [ 763 | "wrUupyRDJMZCrJT-EIkr2" 764 | ], 765 | "roundness": null, 766 | "boundElements": [], 767 | "updated": 1682459400860, 768 | "link": null, 769 | "locked": false, 770 | "fontSize": 20, 771 | "fontFamily": 1, 772 | "text": "news", 773 | "textAlign": "center", 774 | "verticalAlign": "middle", 775 | "containerId": "GtcbZw9umcTWrEQ2Ma_5A", 776 | "originalText": "news", 777 | "lineHeight": 1.2, 778 | "baseline": 17 779 | }, 780 | { 781 | "type": "arrow", 782 | "version": 326, 783 | "versionNonce": 1800658486, 784 | "isDeleted": false, 785 | "id": "Jqh5ZDuGXIPEPs4oKk13F", 786 | "fillStyle": "hachure", 787 | "strokeWidth": 2, 788 | "strokeStyle": "solid", 789 | "roughness": 0, 790 | "opacity": 100, 791 | "angle": 0, 792 | "x": 103.3823745065938, 793 | "y": -73.18773594783215, 794 | "strokeColor": "#000000", 795 | "backgroundColor": "#ced4da", 796 | "width": 160.62863159179688, 797 | "height": 11.95702056158116, 798 | "seed": 2034702262, 799 | "groupIds": [], 800 | "roundness": { 801 | "type": 2 802 | }, 803 | "boundElements": [], 804 | "updated": 1682459400860, 805 | "link": null, 806 | "locked": false, 807 | "startBinding": { 808 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 809 | "focus": 0.21463549901349063, 810 | "gap": 5.055635848686393 811 | }, 812 | "endBinding": { 813 | "elementId": "GtcbZw9umcTWrEQ2Ma_5A", 814 | "focus": 0.2262766735224383, 815 | "gap": 8.68682861328125 816 | }, 817 | "lastCommittedPoint": null, 818 | "startArrowhead": "arrow", 819 | "endArrowhead": "arrow", 820 | "points": [ 821 | [ 822 | 0, 823 | 0 824 | ], 825 | [ 826 | 160.62863159179688, 827 | 11.95702056158116 828 | ] 829 | ] 830 | }, 831 | { 832 | "type": "arrow", 833 | "version": 213, 834 | "versionNonce": 185124906, 835 | "isDeleted": false, 836 | "id": "LcbBa85LEVGMrzXi9yzNE", 837 | "fillStyle": "hachure", 838 | "strokeWidth": 2, 839 | "strokeStyle": "solid", 840 | "roughness": 0, 841 | "opacity": 100, 842 | "angle": 0, 843 | "x": 367.37874291479693, 844 | "y": -53.04124727792666, 845 | "strokeColor": "#000000", 846 | "backgroundColor": "#ced4da", 847 | "width": 132.2132568359375, 848 | "height": 3.1452565362175235, 849 | "seed": 1261875446, 850 | "groupIds": [], 851 | "roundness": { 852 | "type": 2 853 | }, 854 | "boundElements": [], 855 | "updated": 1682459400860, 856 | "link": null, 857 | "locked": false, 858 | "startBinding": { 859 | "elementId": "GtcbZw9umcTWrEQ2Ma_5A", 860 | "focus": 0.04717585295225702, 861 | "gap": 5.75018310546875 862 | }, 863 | "endBinding": { 864 | "elementId": "5dfGFWAlSm2IIFJO-UQC_", 865 | "focus": -0.3580317961473339, 866 | "gap": 2.8728485107421875 867 | }, 868 | "lastCommittedPoint": null, 869 | "startArrowhead": null, 870 | "endArrowhead": "arrow", 871 | "points": [ 872 | [ 873 | 0, 874 | 0 875 | ], 876 | [ 877 | 132.2132568359375, 878 | -3.1452565362175235 879 | ] 880 | ] 881 | }, 882 | { 883 | "type": "rectangle", 884 | "version": 2137, 885 | "versionNonce": 484878198, 886 | "isDeleted": false, 887 | "id": "6r1pH_4yew2v-zQ2qK13z", 888 | "fillStyle": "hachure", 889 | "strokeWidth": 1, 890 | "strokeStyle": "solid", 891 | "roughness": 1, 892 | "opacity": 100, 893 | "angle": 0, 894 | "x": 529.1909377390157, 895 | "y": 55.786277560538906, 896 | "strokeColor": "#000000", 897 | "backgroundColor": "#82c91e", 898 | "width": 204, 899 | "height": 115, 900 | "seed": 924426154, 901 | "groupIds": [ 902 | "aT-1WLVTWlWIGcScZZqVn" 903 | ], 904 | "roundness": { 905 | "type": 3 906 | }, 907 | "boundElements": [ 908 | { 909 | "type": "text", 910 | "id": "tgp-wYEbyAY3Y0TJOk8RP" 911 | }, 912 | { 913 | "id": "E7iDW5WcVMTFtLbjsePxR", 914 | "type": "arrow" 915 | } 916 | ], 917 | "updated": 1682459400860, 918 | "link": null, 919 | "locked": false 920 | }, 921 | { 922 | "type": "text", 923 | "version": 1070, 924 | "versionNonce": 131216106, 925 | "isDeleted": false, 926 | "id": "tgp-wYEbyAY3Y0TJOk8RP", 927 | "fillStyle": "hachure", 928 | "strokeWidth": 1, 929 | "strokeStyle": "solid", 930 | "roughness": 1, 931 | "opacity": 100, 932 | "angle": 0, 933 | "x": 605.1509520822774, 934 | "y": 79.68627756053891, 935 | "strokeColor": "#000000", 936 | "backgroundColor": "#82c91e", 937 | "width": 52.07997131347656, 938 | "height": 67.2, 939 | "seed": 987503210, 940 | "groupIds": [ 941 | "aT-1WLVTWlWIGcScZZqVn" 942 | ], 943 | "roundness": null, 944 | "boundElements": [], 945 | "updated": 1682459400860, 946 | "link": null, 947 | "locked": false, 948 | "fontSize": 28, 949 | "fontFamily": 1, 950 | "text": "SNS\n", 951 | "textAlign": "center", 952 | "verticalAlign": "middle", 953 | "containerId": "6r1pH_4yew2v-zQ2qK13z", 954 | "originalText": "SNS\n", 955 | "lineHeight": 1.2, 956 | "baseline": 58 957 | }, 958 | { 959 | "type": "rectangle", 960 | "version": 593, 961 | "versionNonce": 1003374774, 962 | "isDeleted": false, 963 | "id": "yeAbSyqqA4KYinMJ6RcXq", 964 | "fillStyle": "solid", 965 | "strokeWidth": 1, 966 | "strokeStyle": "solid", 967 | "roughness": 1, 968 | "opacity": 100, 969 | "angle": 0, 970 | "x": 536.9585768991719, 971 | "y": 125.90496042186703, 972 | "strokeColor": "#000000", 973 | "backgroundColor": "#868e96", 974 | "width": 186.8724365234375, 975 | "height": 8.55596923828125, 976 | "seed": 2114010410, 977 | "groupIds": [ 978 | "aT-1WLVTWlWIGcScZZqVn" 979 | ], 980 | "roundness": { 981 | "type": 3 982 | }, 983 | "boundElements": [ 984 | { 985 | "id": "P8gAU51JrZn8J4WCvcdm7", 986 | "type": "arrow" 987 | }, 988 | { 989 | "id": "E7iDW5WcVMTFtLbjsePxR", 990 | "type": "arrow" 991 | } 992 | ], 993 | "updated": 1682459400860, 994 | "link": null, 995 | "locked": false 996 | }, 997 | { 998 | "type": "text", 999 | "version": 438, 1000 | "versionNonce": 1705002410, 1001 | "isDeleted": false, 1002 | "id": "Uw2O6dCQ32DtJMN-eLNnv", 1003 | "fillStyle": "hachure", 1004 | "strokeWidth": 1, 1005 | "strokeStyle": "solid", 1006 | "roughness": 1, 1007 | "opacity": 100, 1008 | "angle": 0, 1009 | "x": 587.9671218210469, 1010 | "y": 136.58293893749203, 1011 | "strokeColor": "#000000", 1012 | "backgroundColor": "#82c91e", 1013 | "width": 98.19989013671875, 1014 | "height": 24, 1015 | "seed": 1307838442, 1016 | "groupIds": [ 1017 | "aT-1WLVTWlWIGcScZZqVn" 1018 | ], 1019 | "roundness": null, 1020 | "boundElements": [], 1021 | "updated": 1682459400860, 1022 | "link": null, 1023 | "locked": false, 1024 | "fontSize": 20, 1025 | "fontFamily": 1, 1026 | "text": "news-topic", 1027 | "textAlign": "left", 1028 | "verticalAlign": "top", 1029 | "containerId": null, 1030 | "originalText": "news-topic", 1031 | "lineHeight": 1.2, 1032 | "baseline": 17 1033 | }, 1034 | { 1035 | "type": "rectangle", 1036 | "version": 2257, 1037 | "versionNonce": 2008007158, 1038 | "isDeleted": false, 1039 | "id": "RylcZAcy9LNhkyc96WrMN", 1040 | "fillStyle": "hachure", 1041 | "strokeWidth": 1, 1042 | "strokeStyle": "solid", 1043 | "roughness": 1, 1044 | "opacity": 100, 1045 | "angle": 0, 1046 | "x": 217.37141869604693, 1047 | "y": 57.377708224601406, 1048 | "strokeColor": "#000000", 1049 | "backgroundColor": "#7950f2", 1050 | "width": 204, 1051 | "height": 141, 1052 | "seed": 314538666, 1053 | "groupIds": [ 1054 | "CVe-Itv1yPoxxNs2JyKPJ" 1055 | ], 1056 | "roundness": { 1057 | "type": 3 1058 | }, 1059 | "boundElements": [ 1060 | { 1061 | "type": "text", 1062 | "id": "i5lBTupict9PZKPjs1uvb" 1063 | } 1064 | ], 1065 | "updated": 1682459400860, 1066 | "link": null, 1067 | "locked": false 1068 | }, 1069 | { 1070 | "type": "text", 1071 | "version": 1177, 1072 | "versionNonce": 1839071338, 1073 | "isDeleted": false, 1074 | "id": "i5lBTupict9PZKPjs1uvb", 1075 | "fillStyle": "hachure", 1076 | "strokeWidth": 1, 1077 | "strokeStyle": "solid", 1078 | "roughness": 1, 1079 | "opacity": 100, 1080 | "angle": 0, 1081 | "x": 292.4774367624532, 1082 | "y": 77.4777082246014, 1083 | "strokeColor": "#000000", 1084 | "backgroundColor": "#7950f2", 1085 | "width": 53.7879638671875, 1086 | "height": 100.80000000000001, 1087 | "seed": 2064553322, 1088 | "groupIds": [ 1089 | "CVe-Itv1yPoxxNs2JyKPJ" 1090 | ], 1091 | "roundness": null, 1092 | "boundElements": [], 1093 | "updated": 1682459400860, 1094 | "link": null, 1095 | "locked": false, 1096 | "fontSize": 28, 1097 | "fontFamily": 1, 1098 | "text": "SQS\n\n", 1099 | "textAlign": "center", 1100 | "verticalAlign": "middle", 1101 | "containerId": "RylcZAcy9LNhkyc96WrMN", 1102 | "originalText": "SQS\n\n", 1103 | "lineHeight": 1.2, 1104 | "baseline": 91 1105 | }, 1106 | { 1107 | "type": "rectangle", 1108 | "version": 413, 1109 | "versionNonce": 1133515574, 1110 | "isDeleted": false, 1111 | "id": "cBpyStQM1WnHQ9-gG5N1g", 1112 | "fillStyle": "solid", 1113 | "strokeWidth": 1, 1114 | "strokeStyle": "solid", 1115 | "roughness": 1, 1116 | "opacity": 100, 1117 | "angle": 0, 1118 | "x": 230.08723900854693, 1119 | "y": 127.19649484569516, 1120 | "strokeColor": "#000000", 1121 | "backgroundColor": "#868e96", 1122 | "width": 178.96466064453125, 1123 | "height": 9.47418212890625, 1124 | "seed": 673994794, 1125 | "groupIds": [ 1126 | "CVe-Itv1yPoxxNs2JyKPJ" 1127 | ], 1128 | "roundness": { 1129 | "type": 3 1130 | }, 1131 | "boundElements": [ 1132 | { 1133 | "id": "P8gAU51JrZn8J4WCvcdm7", 1134 | "type": "arrow" 1135 | }, 1136 | { 1137 | "id": "WvRDE7o2WCeeRfaHm7o38", 1138 | "type": "arrow" 1139 | } 1140 | ], 1141 | "updated": 1682459400860, 1142 | "link": null, 1143 | "locked": false 1144 | }, 1145 | { 1146 | "type": "text", 1147 | "version": 745, 1148 | "versionNonce": 194405162, 1149 | "isDeleted": false, 1150 | "id": "bYhsFszrC1cGvuRJS4Sa4", 1151 | "fillStyle": "hachure", 1152 | "strokeWidth": 1, 1153 | "strokeStyle": "solid", 1154 | "roughness": 1, 1155 | "opacity": 100, 1156 | "angle": 0, 1157 | "x": 249.3505141550313, 1158 | "y": 143.05294015819516, 1159 | "strokeColor": "#000000", 1160 | "backgroundColor": "#7950f2", 1161 | "width": 138.17984008789062, 1162 | "height": 48, 1163 | "seed": 1510195946, 1164 | "groupIds": [ 1165 | "CVe-Itv1yPoxxNs2JyKPJ" 1166 | ], 1167 | "roundness": null, 1168 | "boundElements": [], 1169 | "updated": 1682459400860, 1170 | "link": null, 1171 | "locked": false, 1172 | "fontSize": 20, 1173 | "fontFamily": 1, 1174 | "text": "news-consumer\n-service-queue", 1175 | "textAlign": "left", 1176 | "verticalAlign": "top", 1177 | "containerId": null, 1178 | "originalText": "news-consumer\n-service-queue", 1179 | "lineHeight": 1.2, 1180 | "baseline": 41 1181 | }, 1182 | { 1183 | "type": "arrow", 1184 | "version": 610, 1185 | "versionNonce": 1775516790, 1186 | "isDeleted": false, 1187 | "id": "s75NFoH7QND6gLrkoAHQN", 1188 | "fillStyle": "solid", 1189 | "strokeWidth": 2, 1190 | "strokeStyle": "solid", 1191 | "roughness": 0, 1192 | "opacity": 100, 1193 | "angle": 0, 1194 | "x": 21.96089623510943, 1195 | "y": -224.17115041797672, 1196 | "strokeColor": "#000000", 1197 | "backgroundColor": "#7950f2", 1198 | "width": 48.103090450249795, 1199 | "height": 71.88839721679688, 1200 | "seed": 999427626, 1201 | "groupIds": [], 1202 | "roundness": { 1203 | "type": 2 1204 | }, 1205 | "boundElements": [], 1206 | "updated": 1682459400860, 1207 | "link": null, 1208 | "locked": false, 1209 | "startBinding": null, 1210 | "endBinding": { 1211 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 1212 | "focus": -0.2118805993689112, 1213 | "gap": 8.693622694339922 1214 | }, 1215 | "lastCommittedPoint": null, 1216 | "startArrowhead": null, 1217 | "endArrowhead": "arrow", 1218 | "points": [ 1219 | [ 1220 | 0, 1221 | 0 1222 | ], 1223 | [ 1224 | -44.850311279296875, 1225 | 3.5662841796875 1226 | ], 1227 | [ 1228 | -48.103090450249795, 1229 | 71.88839721679688 1230 | ] 1231 | ] 1232 | }, 1233 | { 1234 | "type": "arrow", 1235 | "version": 355, 1236 | "versionNonce": 2046062058, 1237 | "isDeleted": false, 1238 | "id": "E7iDW5WcVMTFtLbjsePxR", 1239 | "fillStyle": "solid", 1240 | "strokeWidth": 2, 1241 | "strokeStyle": "solid", 1242 | "roughness": 0, 1243 | "opacity": 100, 1244 | "angle": 0, 1245 | "x": 625.1826369577657, 1246 | "y": -24.76813528125797, 1247 | "strokeColor": "#000000", 1248 | "backgroundColor": "transparent", 1249 | "width": 129.972412109375, 1250 | "height": 137.494477417923, 1251 | "seed": 107029046, 1252 | "groupIds": [], 1253 | "roundness": { 1254 | "type": 2 1255 | }, 1256 | "boundElements": [], 1257 | "updated": 1682459400860, 1258 | "link": null, 1259 | "locked": false, 1260 | "startBinding": { 1261 | "elementId": "5dfGFWAlSm2IIFJO-UQC_", 1262 | "focus": 0.5743034810956146, 1263 | "gap": 3.362010850581953 1264 | }, 1265 | "endBinding": { 1266 | "elementId": "yeAbSyqqA4KYinMJ6RcXq", 1267 | "focus": 0.927917166480886, 1268 | "gap": 13.1865234375 1269 | }, 1270 | "lastCommittedPoint": null, 1271 | "startArrowhead": null, 1272 | "endArrowhead": "arrow", 1273 | "points": [ 1274 | [ 1275 | 0, 1276 | 0 1277 | ], 1278 | [ 1279 | 129.972412109375, 1280 | 45.53070068359375 1281 | ], 1282 | [ 1283 | 99.10490736267684, 1284 | 137.494477417923 1285 | ] 1286 | ] 1287 | }, 1288 | { 1289 | "type": "arrow", 1290 | "version": 174, 1291 | "versionNonce": 541146550, 1292 | "isDeleted": false, 1293 | "id": "P8gAU51JrZn8J4WCvcdm7", 1294 | "fillStyle": "solid", 1295 | "strokeWidth": 2, 1296 | "strokeStyle": "solid", 1297 | "roughness": 0, 1298 | "opacity": 100, 1299 | "angle": 0, 1300 | "x": 530.1092116647969, 1301 | "y": 130.6154533175635, 1302 | "strokeColor": "#000000", 1303 | "backgroundColor": "transparent", 1304 | "width": 115.03454589843744, 1305 | "height": 2.2197714091682315, 1306 | "seed": 1839950710, 1307 | "groupIds": [], 1308 | "roundness": { 1309 | "type": 2 1310 | }, 1311 | "boundElements": [], 1312 | "updated": 1682459400860, 1313 | "link": null, 1314 | "locked": false, 1315 | "startBinding": { 1316 | "elementId": "yeAbSyqqA4KYinMJ6RcXq", 1317 | "focus": 0.24692172980123847, 1318 | "gap": 6.849365234375 1319 | }, 1320 | "endBinding": { 1321 | "elementId": "cBpyStQM1WnHQ9-gG5N1g", 1322 | "focus": 0.4246050526858956, 1323 | "gap": 6.022766113281307 1324 | }, 1325 | "lastCommittedPoint": null, 1326 | "startArrowhead": null, 1327 | "endArrowhead": "arrow", 1328 | "points": [ 1329 | [ 1330 | 0, 1331 | 0 1332 | ], 1333 | [ 1334 | -115.03454589843744, 1335 | 2.2197714091682315 1336 | ] 1337 | ] 1338 | }, 1339 | { 1340 | "type": "arrow", 1341 | "version": 619, 1342 | "versionNonce": 1841504426, 1343 | "isDeleted": false, 1344 | "id": "WvRDE7o2WCeeRfaHm7o38", 1345 | "fillStyle": "solid", 1346 | "strokeWidth": 2, 1347 | "strokeStyle": "solid", 1348 | "roughness": 0, 1349 | "opacity": 100, 1350 | "angle": 0, 1351 | "x": 222.71273124489213, 1352 | "y": 132.68461249200485, 1353 | "strokeColor": "#000000", 1354 | "backgroundColor": "transparent", 1355 | "width": 124.53757109376704, 1356 | "height": 10.0207852730654, 1357 | "seed": 526027958, 1358 | "groupIds": [], 1359 | "roundness": { 1360 | "type": 2 1361 | }, 1362 | "boundElements": [ 1363 | { 1364 | "type": "text", 1365 | "id": "jr88amTFsHNT7oB9od6KB" 1366 | } 1367 | ], 1368 | "updated": 1682459400860, 1369 | "link": null, 1370 | "locked": false, 1371 | "startBinding": { 1372 | "elementId": "cBpyStQM1WnHQ9-gG5N1g", 1373 | "focus": 0.8226354924961115, 1374 | "gap": 7.374507763654805 1375 | }, 1376 | "endBinding": { 1377 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 1378 | "focus": 0.08880989502302694, 1379 | "gap": 1.8645980173360215 1380 | }, 1381 | "lastCommittedPoint": null, 1382 | "startArrowhead": null, 1383 | "endArrowhead": "arrow", 1384 | "points": [ 1385 | [ 1386 | 0, 1387 | 0 1388 | ], 1389 | [ 1390 | -47.307377001970195, 1391 | 9.45908694353406 1392 | ], 1393 | [ 1394 | -124.53757109376704, 1395 | 10.0207852730654 1396 | ] 1397 | ] 1398 | }, 1399 | { 1400 | "type": "text", 1401 | "version": 25, 1402 | "versionNonce": 469505782, 1403 | "isDeleted": false, 1404 | "id": "jr88amTFsHNT7oB9od6KB", 1405 | "fillStyle": "solid", 1406 | "strokeWidth": 2, 1407 | "strokeStyle": "solid", 1408 | "roughness": 0, 1409 | "opacity": 100, 1410 | "angle": 0, 1411 | "x": 146.9653080026961, 1412 | "y": 132.95379462239185, 1413 | "strokeColor": "#000000", 1414 | "backgroundColor": "transparent", 1415 | "width": 31.4599609375, 1416 | "height": 24, 1417 | "seed": 685104953, 1418 | "groupIds": [], 1419 | "roundness": null, 1420 | "boundElements": [], 1421 | "updated": 1682459400860, 1422 | "link": null, 1423 | "locked": false, 1424 | "fontSize": 20, 1425 | "fontFamily": 1, 1426 | "text": "poll", 1427 | "textAlign": "center", 1428 | "verticalAlign": "middle", 1429 | "containerId": "WvRDE7o2WCeeRfaHm7o38", 1430 | "originalText": "poll", 1431 | "lineHeight": 1.2, 1432 | "baseline": 17 1433 | }, 1434 | { 1435 | "type": "arrow", 1436 | "version": 819, 1437 | "versionNonce": 1118665578, 1438 | "isDeleted": false, 1439 | "id": "turVDsRIFdQK33RASnQwn", 1440 | "fillStyle": "solid", 1441 | "strokeWidth": 2, 1442 | "strokeStyle": "solid", 1443 | "roughness": 0, 1444 | "opacity": 100, 1445 | "angle": 0, 1446 | "x": -13.473446705944625, 1447 | "y": 81.43953683788266, 1448 | "strokeColor": "#000000", 1449 | "backgroundColor": "transparent", 1450 | "width": 2.7250184138174625, 1451 | "height": 116.73037719726562, 1452 | "seed": 219155370, 1453 | "groupIds": [], 1454 | "roundness": { 1455 | "type": 2 1456 | }, 1457 | "boundElements": [ 1458 | { 1459 | "type": "text", 1460 | "id": "3vMl8ThRPHW5yPfH3ZeLC" 1461 | } 1462 | ], 1463 | "updated": 1682459400860, 1464 | "link": null, 1465 | "locked": false, 1466 | "startBinding": { 1467 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 1468 | "focus": -0.06358294826718336, 1469 | "gap": 7.711334228515625 1470 | }, 1471 | "endBinding": { 1472 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 1473 | "focus": 0.03139886521899117, 1474 | "gap": 8.627574815425703 1475 | }, 1476 | "lastCommittedPoint": null, 1477 | "startArrowhead": "arrow", 1478 | "endArrowhead": "arrow", 1479 | "points": [ 1480 | [ 1481 | 0, 1482 | 0 1483 | ], 1484 | [ 1485 | 1.5413070523821801, 1486 | -57.4840087890625 1487 | ], 1488 | [ 1489 | 2.7250184138174625, 1490 | -116.73037719726562 1491 | ] 1492 | ] 1493 | }, 1494 | { 1495 | "type": "text", 1496 | "version": 111, 1497 | "versionNonce": 629786678, 1498 | "isDeleted": false, 1499 | "id": "3vMl8ThRPHW5yPfH3ZeLC", 1500 | "fillStyle": "solid", 1501 | "strokeWidth": 2, 1502 | "strokeStyle": "solid", 1503 | "roughness": 0, 1504 | "opacity": 100, 1505 | "angle": 0, 1506 | "x": -54.34803165345589, 1507 | "y": 25.66814401561703, 1508 | "strokeColor": "#000000", 1509 | "backgroundColor": "transparent", 1510 | "width": 85.57992553710938, 1511 | "height": 24, 1512 | "seed": 1521043415, 1513 | "groupIds": [], 1514 | "roundness": null, 1515 | "boundElements": [], 1516 | "updated": 1682459400860, 1517 | "link": null, 1518 | "locked": false, 1519 | "fontSize": 20, 1520 | "fontFamily": 1, 1521 | "text": "get news", 1522 | "textAlign": "center", 1523 | "verticalAlign": "middle", 1524 | "containerId": "turVDsRIFdQK33RASnQwn", 1525 | "originalText": "get news", 1526 | "lineHeight": 1.2, 1527 | "baseline": 17 1528 | } 1529 | ], 1530 | "appState": { 1531 | "gridSize": null, 1532 | "viewBackgroundColor": "#ffffff" 1533 | }, 1534 | "files": {} 1535 | } -------------------------------------------------------------------------------- /documentation/project-diagram.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-aws-localstack-dynamodb-lambda-sns-sqs/14a0ecf81be9a8512f93487d7f4a183dd63a139b/documentation/project-diagram.jpeg -------------------------------------------------------------------------------- /dynamodb-lambda-function/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.ivanfranchin 7 | springboot-aws-localstack-dynamodb-lambda-sns-sqs 8 | 1.0.0 9 | ../pom.xml 10 | 11 | dynamodb-lambda-function 12 | dynamodb-lambda-function 13 | Demo project for Spring Boot 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 2024.0.0 29 | 1.2.3 30 | 3.15.0 31 | 1.0.31.RELEASE 32 | 33 | 34 | 35 | org.springframework.cloud 36 | spring-cloud-function-adapter-aws 37 | 38 | 39 | org.springframework.boot 40 | spring-boot-starter-validation 41 | 42 | 43 | io.awspring.cloud 44 | spring-cloud-aws-starter-sns 45 | 46 | 47 | 48 | 49 | com.amazonaws 50 | aws-lambda-java-core 51 | ${aws-lambda-java-core.version} 52 | 53 | 54 | com.amazonaws 55 | aws-lambda-java-events 56 | ${aws-lambda-java-events.version} 57 | 58 | 59 | 60 | org.projectlombok 61 | lombok 62 | true 63 | 64 | 65 | org.springframework.boot 66 | spring-boot-starter-test 67 | test 68 | 69 | 70 | 71 | 72 | 73 | org.springframework.cloud 74 | spring-cloud-dependencies 75 | ${spring-cloud.version} 76 | pom 77 | import 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | org.apache.maven.plugins 86 | maven-deploy-plugin 87 | 88 | true 89 | 90 | 91 | 92 | org.apache.maven.plugins 93 | maven-compiler-plugin 94 | 95 | 96 | 97 | org.projectlombok 98 | lombok 99 | 100 | 101 | 102 | 103 | 104 | org.springframework.boot 105 | spring-boot-maven-plugin 106 | 107 | 108 | 109 | org.projectlombok 110 | lombok 111 | 112 | 113 | 114 | 115 | 116 | org.springframework.boot.experimental 117 | spring-boot-thin-layout 118 | ${spring-boot-thin-layout.version} 119 | 120 | 121 | 122 | 123 | org.apache.maven.plugins 124 | maven-shade-plugin 125 | 126 | false 127 | true 128 | aws 129 | dynamodb-lambda-function-java${java.version}-aws 130 | 131 | 132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /dynamodb-lambda-function/src/main/java/com/ivanfranchin/dynamodblambdafunction/DynamodbLambdaFunctionApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.dynamodblambdafunction; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class DynamodbLambdaFunctionApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(DynamodbLambdaFunctionApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /dynamodb-lambda-function/src/main/java/com/ivanfranchin/dynamodblambdafunction/handler/DynamodbEventHandler.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.dynamodblambdafunction.handler; 2 | 3 | import com.amazonaws.services.lambda.runtime.events.DynamodbEvent; 4 | import com.amazonaws.services.lambda.runtime.events.models.dynamodb.AttributeValue; 5 | import com.ivanfranchin.dynamodblambdafunction.news.NewsEvent; 6 | import com.ivanfranchin.dynamodblambdafunction.news.NewsEventPublisher; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.util.Map; 11 | import java.util.function.Consumer; 12 | 13 | @RequiredArgsConstructor 14 | @Component 15 | public class DynamodbEventHandler implements Consumer { 16 | 17 | private final NewsEventPublisher newsEventPublisher; 18 | 19 | @Override 20 | public void accept(DynamodbEvent dynamodbEvent) { 21 | dynamodbEvent.getRecords() 22 | .stream() 23 | .map(this::toNewsEvent) 24 | .forEach(newsEventPublisher::publish); 25 | } 26 | 27 | private NewsEvent toNewsEvent(DynamodbEvent.DynamodbStreamRecord record) { 28 | Map image = record.getDynamodb().getNewImage(); 29 | if (image == null) { 30 | image = record.getDynamodb().getOldImage(); 31 | } 32 | return new NewsEvent( 33 | record.getEventName(), 34 | new NewsEvent.News( 35 | image.get("id").getS(), 36 | image.get("title").getS(), 37 | image.get("publishedAt").getS() 38 | ) 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /dynamodb-lambda-function/src/main/java/com/ivanfranchin/dynamodblambdafunction/news/NewsEvent.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.dynamodblambdafunction.news; 2 | 3 | public record NewsEvent(String action, News news) { 4 | public record News(String id, String title, String publishedAt) { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /dynamodb-lambda-function/src/main/java/com/ivanfranchin/dynamodblambdafunction/news/NewsEventPublisher.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.dynamodblambdafunction.news; 2 | 3 | import com.ivanfranchin.dynamodblambdafunction.properties.AwsProperties; 4 | import io.awspring.cloud.sns.core.SnsTemplate; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Component; 7 | 8 | @RequiredArgsConstructor 9 | @Component 10 | public class NewsEventPublisher { 11 | 12 | private final SnsTemplate snsTemplate; 13 | private final AwsProperties awsProperties; 14 | 15 | public void publish(NewsEvent newsEvent) { 16 | snsTemplate.convertAndSend(awsProperties.getSns().getDestination(), newsEvent); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /dynamodb-lambda-function/src/main/java/com/ivanfranchin/dynamodblambdafunction/properties/AwsProperties.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.dynamodblambdafunction.properties; 2 | 3 | import jakarta.validation.Valid; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.Data; 7 | import org.springframework.boot.context.properties.ConfigurationProperties; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.validation.annotation.Validated; 10 | 11 | @Data 12 | @Validated 13 | @Configuration 14 | @ConfigurationProperties(prefix = "aws") 15 | public class AwsProperties { 16 | 17 | @NotNull 18 | private SNS sns; 19 | 20 | @Data 21 | @Valid 22 | public static class SNS { 23 | 24 | @NotBlank 25 | private String destination; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /dynamodb-lambda-function/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=dynamodb-lambda-function 2 | spring.cloud.aws.sns.endpoint=http://localhost.localstack.cloud:4566 3 | logging.level.org.springframework.cloud=DEBUG 4 | aws.sns.destination=news-topic -------------------------------------------------------------------------------- /dynamodb-lambda-function/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ _ _ _ _ _ __ _ _ 2 | __| |_ _ _ __ __ _ _ __ ___ ___ __| | |__ | | __ _ _ __ ___ | |__ __| | __ _ / _|_ _ _ __ ___| |_(_) ___ _ __ 3 | / _` | | | | '_ \ / _` | '_ ` _ \ / _ \ / _` | '_ \ _____| |/ _` | '_ ` _ \| '_ \ / _` |/ _` |_____| |_| | | | '_ \ / __| __| |/ _ \| '_ \ 4 | | (_| | |_| | | | | (_| | | | | | | (_) | (_| | |_) |_____| | (_| | | | | | | |_) | (_| | (_| |_____| _| |_| | | | | (__| |_| | (_) | | | | 5 | \__,_|\__, |_| |_|\__,_|_| |_| |_|\___/ \__,_|_.__/ |_|\__,_|_| |_| |_|_.__/ \__,_|\__,_| |_| \__,_|_| |_|\___|\__|_|\___/|_| |_| 6 | |___/ 7 | :: Spring Boot :: ${spring-boot.formatted-version} 8 | -------------------------------------------------------------------------------- /dynamodb-lambda-function/src/test/java/com/ivanfranchin/dynamodblambdafunction/DynamodbLambdaFunctionApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.dynamodblambdafunction; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | @Disabled 8 | @SpringBootTest 9 | class DynamodbLambdaFunctionApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /init-localstack.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! [[ $(docker ps -q -f name=localstack) ]]; then 4 | echo "WARNING: The localstack Docker container is not running. Please, start it first." 5 | exit 1 6 | fi 7 | 8 | echo 9 | echo "Initializing LocalStack" 10 | echo "=======================" 11 | 12 | echo 13 | echo "Installing jq" 14 | echo "-------------" 15 | docker exec -t localstack apt-get -y install jq 16 | 17 | echo 18 | echo "Creating news-topic in SNS" 19 | echo "--------------------------" 20 | docker exec -t localstack aws --endpoint-url=http://localhost:4566 sns create-topic --name news-topic 21 | 22 | echo 23 | echo "Creating news-consumer-queue in SQS" 24 | echo "-----------------------------------" 25 | docker exec -t localstack aws --endpoint-url=http://localhost:4566 sqs create-queue --queue-name news-consumer-queue 26 | 27 | echo 28 | echo "Subscribing news-consumer-queue to news-topic" 29 | echo "---------------------------------------------" 30 | docker exec -t localstack aws --endpoint-url=http://localhost:4566 sns subscribe \ 31 | --topic-arn arn:aws:sns:eu-west-1:000000000000:news-topic \ 32 | --protocol sqs \ 33 | --attributes '{"RawMessageDelivery":"true"}' \ 34 | --notification-endpoint arn:aws:sqs:eu-west-1:000000000000:news-consumer-queue 35 | 36 | echo 37 | echo "Creating news table in DynamoDB" 38 | echo "-------------------------------" 39 | docker exec -t localstack aws --endpoint-url=http://localhost:4566 dynamodb create-table \ 40 | --table-name news \ 41 | --attribute-definitions AttributeName=id,AttributeType=S \ 42 | --key-schema AttributeName=id,KeyType=HASH \ 43 | --provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5 \ 44 | --stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES 45 | 46 | echo 47 | echo "Getting news table DynamoDB Stream ARN" 48 | echo "--------------------------------------" 49 | NEWS_TABLE_DYNAMODB_STREAM_ARN=$(docker exec -t localstack aws --endpoint-url=http://localhost:4566 dynamodb describe-table --table-name news | jq -r '.Table.LatestStreamArn') 50 | echo "NEWS_TABLE_DYNAMODB_STREAM_ARN=${NEWS_TABLE_DYNAMODB_STREAM_ARN}" 51 | 52 | echo 53 | echo "Creating Lambda Function called ProcessDynamoDBEvent" 54 | echo "----------------------------------------------------" 55 | docker exec -t localstack aws --endpoint-url=http://localhost:4566 lambda create-function \ 56 | --function-name ProcessDynamoDBEvent \ 57 | --runtime java21 \ 58 | --memory-size 512 \ 59 | --handler org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest \ 60 | --zip-file fileb:///shared/dynamodb-lambda-function-java21-aws.jar \ 61 | --environment "Variables={AWS_REGION=eu-west-1,AWS_ACCESS_KEY_ID=key,AWS_SECRET_ACCESS_KEY=secret}" \ 62 | --role arn:aws:iam::000000000000:role/service-role/irrelevant \ 63 | --timeout 60 64 | 65 | echo 66 | echo "Creating a mapping between news table DynamoDB event source and ProcessDynamoDBEvent lambda function" 67 | echo "----------------------------------------------------------------------------------------------------" 68 | docker exec -t localstack aws --endpoint-url=http://localhost:4566 lambda create-event-source-mapping \ 69 | --function-name ProcessDynamoDBEvent \ 70 | --event-source $NEWS_TABLE_DYNAMODB_STREAM_ARN \ 71 | --starting-position LATEST 72 | 73 | echo 74 | echo "LocalStack initialized successfully" 75 | echo "===================================" 76 | echo -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /news-consumer/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.ivanfranchin 7 | springboot-aws-localstack-dynamodb-lambda-sns-sqs 8 | 1.0.0 9 | ../pom.xml 10 | 11 | news-consumer 12 | news-consumer 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-actuator 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-thymeleaf 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-validation 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-web 43 | 44 | 45 | org.springframework.boot 46 | spring-boot-starter-websocket 47 | 48 | 49 | io.awspring.cloud 50 | spring-cloud-aws-starter-sqs 51 | 52 | 53 | 54 | org.projectlombok 55 | lombok 56 | true 57 | 58 | 59 | org.springframework.boot 60 | spring-boot-starter-test 61 | test 62 | 63 | 64 | 65 | 66 | 67 | 68 | org.apache.maven.plugins 69 | maven-compiler-plugin 70 | 71 | 72 | 73 | org.projectlombok 74 | lombok 75 | 76 | 77 | 78 | 79 | 80 | org.springframework.boot 81 | spring-boot-maven-plugin 82 | 83 | 84 | 85 | org.projectlombok 86 | lombok 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /news-consumer/src/main/java/com/ivanfranchin/newsconsumer/NewsConsumerApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsconsumer; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class NewsConsumerApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(NewsConsumerApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /news-consumer/src/main/java/com/ivanfranchin/newsconsumer/client/NewsProducerClient.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsconsumer.client; 2 | 3 | import org.springframework.web.service.annotation.GetExchange; 4 | import org.springframework.web.service.annotation.HttpExchange; 5 | 6 | import java.util.List; 7 | 8 | @HttpExchange("/api/news") 9 | public interface NewsProducerClient { 10 | 11 | @GetExchange 12 | List getNews(); 13 | } 14 | -------------------------------------------------------------------------------- /news-consumer/src/main/java/com/ivanfranchin/newsconsumer/client/NewsProducerClientConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsconsumer.client; 2 | 3 | import org.springframework.beans.factory.annotation.Value; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.client.RestClient; 7 | import org.springframework.web.client.support.RestClientAdapter; 8 | import org.springframework.web.service.invoker.HttpServiceProxyFactory; 9 | 10 | @Configuration 11 | public class NewsProducerClientConfig { 12 | 13 | @Value("${news-producer.url}") 14 | private String newsProducerUrl; 15 | 16 | @Bean 17 | NewsProducerClient newsProducerClient(RestClient.Builder builder) { 18 | RestClient restClient = builder.baseUrl(newsProducerUrl).build(); 19 | RestClientAdapter adapter = RestClientAdapter.create(restClient); 20 | HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); 21 | return factory.createClient(NewsProducerClient.class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /news-consumer/src/main/java/com/ivanfranchin/newsconsumer/client/NewsResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsconsumer.client; 2 | 3 | import java.util.Date; 4 | 5 | public record NewsResponse(String id, String title, Date publishedAt) { 6 | } 7 | -------------------------------------------------------------------------------- /news-consumer/src/main/java/com/ivanfranchin/newsconsumer/config/WebSocketConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsconsumer.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.messaging.simp.config.MessageBrokerRegistry; 5 | import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; 6 | import org.springframework.web.socket.config.annotation.StompEndpointRegistry; 7 | import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; 8 | 9 | @Configuration 10 | @EnableWebSocketMessageBroker 11 | public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { 12 | 13 | @Override 14 | public void registerStompEndpoints(StompEndpointRegistry registry) { 15 | registry.addEndpoint("/news-websocket").withSockJS(); 16 | } 17 | 18 | @Override 19 | public void configureMessageBroker(MessageBrokerRegistry registry) { 20 | registry.setApplicationDestinationPrefixes("/app"); 21 | registry.enableSimpleBroker("/topic"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /news-consumer/src/main/java/com/ivanfranchin/newsconsumer/news/NewsController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsconsumer.news; 2 | 3 | import com.ivanfranchin.newsconsumer.client.NewsProducerClient; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.stereotype.Controller; 6 | import org.springframework.ui.Model; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | 9 | @RequiredArgsConstructor 10 | @Controller 11 | public class NewsController { 12 | 13 | private final NewsProducerClient newsProducerClient; 14 | 15 | @GetMapping(value = {"/", "/news"}) 16 | public String getNews(Model model) { 17 | model.addAttribute("newsList", newsProducerClient.getNews()); 18 | return "news"; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /news-consumer/src/main/java/com/ivanfranchin/newsconsumer/news/NewsEvent.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsconsumer.news; 2 | 3 | import java.util.Date; 4 | 5 | public record NewsEvent(String action, News news) { 6 | public record News(String id, String title, Date publishedAt) { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /news-consumer/src/main/java/com/ivanfranchin/newsconsumer/news/NewsEventListener.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsconsumer.news; 2 | 3 | import io.awspring.cloud.sqs.annotation.SqsListener; 4 | import lombok.RequiredArgsConstructor; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.messaging.simp.SimpMessagingTemplate; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Slf4j 10 | @RequiredArgsConstructor 11 | @Component 12 | public class NewsEventListener { 13 | 14 | private final SimpMessagingTemplate simpMessagingTemplate; 15 | 16 | @SqsListener("${aws.sqs.destination}") 17 | public void sqsListener(NewsEvent newsEvent) { 18 | log.info("Received newsEvent: {}", newsEvent); 19 | simpMessagingTemplate.convertAndSend("/topic/news", newsEvent); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /news-consumer/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | server.port=9081 2 | spring.application.name=news-consumer 3 | spring.cloud.aws.sqs.endpoint=http://localhost.localstack.cloud:4566 4 | management.endpoints.web.exposure.include=beans,env,health,info,metrics,mappings 5 | management.endpoint.health.show-details=always 6 | aws.sqs.destination=news-consumer-queue 7 | news-producer.url=http://localhost:9080 -------------------------------------------------------------------------------- /news-consumer/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | 2 | _ __ _____ _____ ___ ___ _ __ ___ _ _ _ __ ___ ___ _ __ 3 | | '_ \ / _ \ \ /\ / / __|_____ / __/ _ \| '_ \/ __| | | | '_ ` _ \ / _ \ '__| 4 | | | | | __/\ V V /\__ \_____| (_| (_) | | | \__ \ |_| | | | | | | __/ | 5 | |_| |_|\___| \_/\_/ |___/ \___\___/|_| |_|___/\__,_|_| |_| |_|\___|_| 6 | :: Spring Boot :: ${spring-boot.formatted-version} 7 | -------------------------------------------------------------------------------- /news-consumer/src/main/resources/static/app.js: -------------------------------------------------------------------------------- 1 | let stompClient = null 2 | 3 | function connectToWebSocket() { 4 | const socket = new SockJS('/news-websocket') 5 | stompClient = Stomp.over(socket) 6 | 7 | stompClient.connect({}, 8 | function (frame) { 9 | console.log('Connected: ' + frame) 10 | $('.connWebSocket').find('i').removeClass('red').addClass('green') 11 | 12 | stompClient.subscribe('/topic/news', function (newsEvent) { 13 | const newsEventBody = JSON.parse(newsEvent.body) 14 | const news = newsEventBody.news 15 | const action = newsEventBody.action 16 | 17 | const $news = $('#' + news.id) 18 | if (action === 'REMOVE' && $news.length !== 0) { 19 | $news.transition({ 20 | animation: 'flash', 21 | onComplete: function() { 22 | $news.remove() 23 | } 24 | } 25 | ) 26 | } else if (action === 'INSERT' && $news.length === 0) { 27 | const newsItem = '
' + 28 | '
' + 29 | '
' + 30 | ''+moment(news.publishedAt).format("DD-MMM-YYYY HH:mm:ss")+'' + 31 | '
' + 32 | '
' + 33 | '
'+news.title+'
' + 34 | '
' + 35 | '
' 36 | $('#newsList').prepend(newsItem) 37 | $('#' + news.id).transition('glow') 38 | } 39 | }) 40 | }, 41 | function() { 42 | showModal($('.modal.alert'), 'WebSocket Disconnected', 'WebSocket is disconnected. Maybe, news-consumer is down or restarting') 43 | $('.connWebSocket').find('i').removeClass('green').addClass('red') 44 | } 45 | ) 46 | } 47 | 48 | function showModal($modal, header, description, fnApprove) { 49 | $modal.find('.header').text(header) 50 | $modal.find('.content').text(description) 51 | $modal.modal('show') 52 | } 53 | 54 | $(function () { 55 | connectToWebSocket() 56 | 57 | $('.connWebSocket').click(function() { 58 | connectToWebSocket() 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /news-consumer/src/main/resources/templates/news.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | News-Consumer 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 21 |
22 | 23 |
24 |
25 |
26 |
27 |
28 |
29 | Datetime 30 |
31 |
32 |
Title
33 |
34 |
35 |
36 |
37 |
38 | 39 | 40 | 47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /news-consumer/src/test/java/com/ivanfranchin/newsconsumer/NewsConsumerApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsconsumer; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | @Disabled 8 | @SpringBootTest 9 | class NewsConsumerApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /news-producer/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.ivanfranchin 7 | springboot-aws-localstack-dynamodb-lambda-sns-sqs 8 | 1.0.0 9 | ../pom.xml 10 | 11 | news-producer 12 | news-producer 13 | Demo project for Spring Boot 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 2.8.5 29 | 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-actuator 34 | 35 | 36 | org.springframework.boot 37 | spring-boot-starter-validation 38 | 39 | 40 | org.springframework.boot 41 | spring-boot-starter-web 42 | 43 | 44 | io.awspring.cloud 45 | spring-cloud-aws-starter-dynamodb 46 | 47 | 48 | 49 | 50 | org.springdoc 51 | springdoc-openapi-starter-webmvc-ui 52 | ${springdoc-openapi.version} 53 | 54 | 55 | 56 | org.projectlombok 57 | lombok 58 | true 59 | 60 | 61 | org.springframework.boot 62 | spring-boot-starter-test 63 | test 64 | 65 | 66 | 67 | 68 | 69 | 70 | org.apache.maven.plugins 71 | maven-compiler-plugin 72 | 73 | 74 | 75 | org.projectlombok 76 | lombok 77 | 78 | 79 | 80 | 81 | 82 | org.springframework.boot 83 | spring-boot-maven-plugin 84 | 85 | 86 | 87 | org.projectlombok 88 | lombok 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /news-producer/src/main/java/com/ivanfranchin/newsproducer/NewsProducerApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class NewsProducerApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(NewsProducerApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /news-producer/src/main/java/com/ivanfranchin/newsproducer/config/ErrorAttributesConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer.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.servlet.error.DefaultErrorAttributes; 6 | import org.springframework.boot.web.servlet.error.ErrorAttributes; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.web.context.request.WebRequest; 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(WebRequest webRequest, ErrorAttributeOptions options) { 21 | return super.getErrorAttributes(webRequest, 22 | options.including(Include.EXCEPTION, Include.MESSAGE, Include.BINDING_ERRORS)); 23 | } 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /news-producer/src/main/java/com/ivanfranchin/newsproducer/config/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer.config; 2 | 3 | import io.swagger.v3.oas.models.Components; 4 | import io.swagger.v3.oas.models.OpenAPI; 5 | import io.swagger.v3.oas.models.info.Info; 6 | import org.springdoc.core.models.GroupedOpenApi; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | 11 | @Configuration 12 | public class SwaggerConfig { 13 | 14 | @Value("${spring.application.name}") 15 | private String applicationName; 16 | 17 | @Bean 18 | OpenAPI customOpenAPI() { 19 | return new OpenAPI().components(new Components()).info(new Info().title(applicationName)); 20 | } 21 | 22 | @Bean 23 | GroupedOpenApi customApi() { 24 | return GroupedOpenApi.builder().group("api").pathsToMatch("/api/**").build(); 25 | } 26 | 27 | @Bean 28 | GroupedOpenApi actuatorApi() { 29 | return GroupedOpenApi.builder().group("actuator").pathsToMatch("/actuator/**").build(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /news-producer/src/main/java/com/ivanfranchin/newsproducer/news/NewsController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer.news; 2 | 3 | import com.ivanfranchin.newsproducer.news.model.News; 4 | import com.ivanfranchin.newsproducer.news.dto.CreateNewsRequest; 5 | import jakarta.validation.Valid; 6 | import lombok.RequiredArgsConstructor; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.web.bind.annotation.DeleteMapping; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.PathVariable; 12 | import org.springframework.web.bind.annotation.PostMapping; 13 | import org.springframework.web.bind.annotation.RequestBody; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.ResponseStatus; 16 | import org.springframework.web.bind.annotation.RestController; 17 | 18 | import java.util.List; 19 | 20 | @Slf4j 21 | @RequiredArgsConstructor 22 | @RestController 23 | @RequestMapping("/api/news") 24 | public class NewsController { 25 | 26 | private final NewsService newsService; 27 | private final RandomNewsGenerator randomNewsGenerator; 28 | 29 | @ResponseStatus(HttpStatus.CREATED) 30 | @PostMapping 31 | public News createNews(@Valid @RequestBody CreateNewsRequest request) { 32 | log.info("Create news {}", request); 33 | return newsService.saveNews(new News(request.title())); 34 | } 35 | 36 | @ResponseStatus(HttpStatus.CREATED) 37 | @PostMapping("/randomly") 38 | public News createNewsRandomly() { 39 | log.info("Create news randomly"); 40 | return newsService.saveNews(randomNewsGenerator.getRandomly()); 41 | } 42 | 43 | @GetMapping("/{id}") 44 | public News getNews(@PathVariable String id) { 45 | log.info("Get news with id {}", id); 46 | return newsService.validateAndGetNews(id); 47 | } 48 | 49 | @GetMapping 50 | public List getNews() { 51 | return newsService.getNews(); 52 | } 53 | 54 | @DeleteMapping("/{id}") 55 | public News deleteNews(@PathVariable String id) { 56 | log.info("Delete news with id {}", id); 57 | News news = newsService.validateAndGetNews(id); 58 | newsService.deleteNews(id); 59 | return news; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /news-producer/src/main/java/com/ivanfranchin/newsproducer/news/NewsService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer.news; 2 | 3 | import com.ivanfranchin.newsproducer.news.exception.NewsNotFoundException; 4 | import com.ivanfranchin.newsproducer.news.model.News; 5 | import io.awspring.cloud.dynamodb.DynamoDbTemplate; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.stereotype.Service; 8 | import software.amazon.awssdk.enhanced.dynamodb.Key; 9 | import software.amazon.awssdk.enhanced.dynamodb.model.PageIterable; 10 | 11 | import java.util.List; 12 | import java.util.stream.StreamSupport; 13 | 14 | @RequiredArgsConstructor 15 | @Service 16 | public class NewsService { 17 | 18 | private final DynamoDbTemplate dynamoDbTemplate; 19 | 20 | public News saveNews(News news) { 21 | return dynamoDbTemplate.save(news); 22 | } 23 | 24 | public List getNews() { 25 | PageIterable news = dynamoDbTemplate.scanAll(News.class); 26 | return StreamSupport.stream(news.spliterator(), false) 27 | .flatMap(page -> page.items().stream()) 28 | .sorted((n1, n2) -> n2.getPublishedAt().compareTo(n1.getPublishedAt())) 29 | .toList(); 30 | } 31 | 32 | public News validateAndGetNews(String id) { 33 | Key key = Key.builder().partitionValue(id).build(); 34 | News news = dynamoDbTemplate.load(key, News.class); 35 | if (news == null) { 36 | throw new NewsNotFoundException("News with id %s not found".formatted(id)); 37 | } 38 | return news; 39 | } 40 | 41 | public void deleteNews(String id) { 42 | News news = validateAndGetNews(id); 43 | dynamoDbTemplate.delete(news); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /news-producer/src/main/java/com/ivanfranchin/newsproducer/news/RandomNewsGenerator.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer.news; 2 | 3 | import com.ivanfranchin.newsproducer.news.model.News; 4 | import org.springframework.stereotype.Component; 5 | 6 | import java.security.SecureRandom; 7 | import java.util.List; 8 | import java.util.Random; 9 | 10 | @Component 11 | public class RandomNewsGenerator { 12 | 13 | public News getRandomly() { 14 | return new News(TITLES.get(random.nextInt(TITLES.size()))); 15 | } 16 | 17 | private final Random random = new SecureRandom(); 18 | 19 | private final static List TITLES = List.of( 20 | "Air defence for Qatar soccer stadiums a boon for Swiss arms exports - Reuters.com", 21 | "Mount Pearl Municipal Workers Strike Enters Sixth Day - VOCM", 22 | "Kelley Blue Book: Not ready for an electric car? These future models could make you change your mind.", 23 | "Red Star FC Recruits Lack of Guidance for '90s-Inspired 2022/23 Kits", 24 | "Lack of Guidance Works Its Magic For Red Star FC 2022/23", 25 | "The Theragun Mini is the travel power move you're missing — get it for 20% off during Prime Day", 26 | "Jets' Jeremy Ruckert gets a big green homecoming, 21 years in the making", 27 | "Gareth Bale says he’s at LAFC to win trophies, not to retire", 28 | "Coach Priestman challenges Canadian women's soccer team to be better as it moves to CONCACAF semifinals", 29 | "Canadian women's soccer team blanks Costa Rica, takes top spot in Group B at CONCACAF Championship", 30 | "59 Years After The Equal Pay Act, Women Still Struggle For The Bare Minimum", 31 | "Bale: \"La Mls non è per pensionati, è un campionato ricco di insidie\"", 32 | "Chuyên gia Indonesia: 'Việt Nam, Thái Lan chẳng làm gì sai'", 33 | "Wayne Rooney agrees to coach DC United", 34 | "Southern Rep Theatre In New Orleans Closes Permanently", 35 | "North American Footwear Market Report 2022-2031: Key Market Dynamics, Players and Trends", 36 | "No Fun Allowed! How the Left Became the Fun Police.", 37 | "Neues Quartett für Aufsteiger Erfurt", 38 | "Midnight Mania! McGregor tells Paul to move along", 39 | "Bill Burr Rips ‘Feminists’ For Failing The WNBA", 40 | "Ja Morant interview leads to spoof quote about Michael Jordan on ESPN - Commercial Appeal", 41 | "DAZN: Galibier y Alpe d´Huez preparan emociones fuertes", 42 | "Gillette Stadium Scores AI Weapons Detection Systems From Evolv Technology", 43 | "NASCAR and SeatGeek Enter Multi-Year Official Partnership", 44 | "USWNT gets ugly win in Mexico, plus why Tiger Woods' last go at St. Andrews is such a big deal", 45 | "元マンUのMFナニがメルボルン・V加入を発表! 「挑戦を楽しみにしている」", 46 | "レヴァンドフスキ、バイエルンの練習場に出現…バルサ行き希望は相変わらずか", 47 | "チェルシー、ニャブリ獲得を検討? ハフィーニャ獲得失敗の次善の策か", 48 | "Concacaf W Championship: USA vs. Mexico - Lineup, Schedule & TV Channels - U.S. Soccer", 49 | "Former Super Bowl champion Osi Umenyiora is creating a new path for African talent to make it to the NFL", 50 | "Laura Freigang, parole libérée", 51 | "How Britan and Germany helped North Korea develop its national beer", 52 | "39 anticipated kids graphic novels for summer 2022", 53 | "HaBO: He Wants Her to Use a Pseudonym", 54 | "Coleen Rooney enjoys post-workout green choice as she leaves Cheshire gym", 55 | "Characteristics of total body and appendicular bone mineral content and density in Japanese collegiate Sumo wrestlers", 56 | "UEFA says rival football Super League plan is a ‘textbook cartel’", 57 | "Prince George's Suit Echoes 'Bizarre Outfits' Harry Laughed About Wearing", 58 | "MLB Unveils 2022 All-Star Game Jerseys", 59 | "Top football referee Igor Benevenuto comes out as gay ahead of World Cup", 60 | "Every League Two club's record signing from £3m dud to tragic Joey Beauchamp", 61 | "J1福岡、MF杉本太郎がJ2徳島に完全移籍!4年ぶりの復帰「とても楽しみにしています」(関連まとめ)", 62 | "東京V、7名が新型コロナ陽性で13日までトップチームの活動停止に…13日の天皇杯磐田戦は20日に延期", 63 | "横浜FM樺山諒乃介、J2山形に2度目の育成型期限付き移籍「今度こそ山形の力になり、J1昇格のために全力を尽くします」", 64 | "清水、加入確実のMFピカチュウが来週にも来日か?移籍金1.4億円、年俸2.3億円の見通し(関連まとめ)", 65 | "You Can Now Be A Shark In Nintendo Switch Sports", 66 | "The Rush: Morant claims he would ‘cook’ Jordan, beat Messi in soccer shootout", 67 | "Mewis scores late, US women top Mexico 1-0 at W Championship", 68 | "Max Norris: 5 Things About Chuck Norris’ 22-Year-Old Grandson On ‘Claim To Fame’", 69 | "UFC midyear awards -- The best male and female fighter, finish, prospect and more", 70 | "How to Save College Football", 71 | "Juventus in tournée negli USA: dove e quando seguire le partite in tv", 72 | "Simone Biles’s inspires other stars to go public on mental health", 73 | "Bale hat sich in Los Angeles viel vorgenommen", 74 | "◆悲報◆ムバッペやネイマールは?PSG7選手の日本ツアー不参加が決定か", 75 | "ESPN host apologizes after airing fake Ja Morant quote about Michael Jordan", 76 | "Air defence for Qatar soccer stadiums a boon for Swiss arms exports", 77 | "Premundial Concacaf: Alex Morgan, la bicampeona mundial y casada con un mexicano", 78 | "Former Manchester United winger Nani joins Melbourne Victory on a two-year contract", 79 | "Man United fans fume at Paul Pogba after he declares himself 'back like I have never left'", 80 | "Hernández: Gareth Bale looks to rewrite his story with LAFC", 81 | "Analysis: USWNT keeps momentum going at CONCACAF W Championship but wasn't easy against Mexico", 82 | "Andonovski: USWNT 'Probably Not Ready' to Compete in World Cup Yet Despite Mexico Win", 83 | "How Britan and Germany helped North Korea develop its national beer", 84 | "Premier League transfer news live, today! Latest updates on the summer window", 85 | "Transfernieuws: Willem II haalt Kostas Lamprou terug", 86 | "Transfernieuws: Portugees Nani sluit voetballoopbaan af in Australië", 87 | "Perplexing goal-line play during CPL match in Winnipeg a buzz within soccer community", 88 | "See gorgeous photos from T.J. Watt and Dani Rhodes' wedding", 89 | "Ranbir: My wife makes me the happiest", 90 | "Former soccer player Dani Rhodes and T.J. Watt of Steelers get married in resort wedding in Mexico", 91 | "PSGの日本ツアー、メッシ・ネイマール・エムバペが揃って来ない可能性、ある?www", 92 | "【画像】ガンバ大阪さん、世界的BIGクラブの仲間入りwww", 93 | "20 Fun Boho Crafts to Make This Summer", 94 | "Bale sets sights on a long-term stay with LAFC", 95 | "Wayne Rooney making mistake in quest to one day become Man Utd manager", 96 | "How the Packers Offense Played In Each Game Without Davante Adams", 97 | "Estrella Damm Barcelona Trip Giveaway ~ 09/05/2022", 98 | "Neo Studios, Roma Press explore Diego Maradona’s legacy in four-part doc", 99 | "UEFA says rival Super League a textbook cartel", 100 | "A lot happening in women's soccer", 101 | "Iran fires coach Skocic 4 months out from soccer World Cup", 102 | "How the Islamic Soccer League became a field of opportunity for Muslim families in the GTA", 103 | "Spain midfielder Rodri signs 3-year extension with Man City", 104 | "Canada beats Costa Rica 2-0, tops group at W Championship", 105 | "Mewis scores late, US women top Mexico 1-0 at W Championship", 106 | "Fomer Man Utd forward Nani joins Melbourne Victory", 107 | "Why Being Famous Won't Sell Your Product", 108 | "The Ins and Outs of Building a Community w/ Adam22", 109 | "Taika Waititi would love to annoy fans with another Thor movie", 110 | "Gareth Bale targets Euro 2024 and beyond after MLS move", 111 | "A unique opportunity for Irish rugby; Cian Lynch’s timely return for Limerick", 112 | "Romeo Beckham splits from girlfriend Mia Regan after three years together", 113 | "Video: Montaigne shows off her soccer skills on Spicks and Specks return", 114 | "City SC signs a young and hungry teenage winger from Denmark", 115 | "ディ・マリア、ユヴェントス加入を決断した理由明かす「当然の帰結と言える」" 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /news-producer/src/main/java/com/ivanfranchin/newsproducer/news/dto/CreateNewsRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer.news.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | public record CreateNewsRequest( 7 | @Schema(example = "Copa Libertadores: Brazil's Palmeiras win title for second year") @NotBlank String title) { 8 | } 9 | -------------------------------------------------------------------------------- /news-producer/src/main/java/com/ivanfranchin/newsproducer/news/exception/NewsNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer.news.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.NOT_FOUND) 7 | public class NewsNotFoundException extends RuntimeException { 8 | 9 | public NewsNotFoundException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /news-producer/src/main/java/com/ivanfranchin/newsproducer/news/model/News.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer.news.model; 2 | 3 | import lombok.Data; 4 | import lombok.NoArgsConstructor; 5 | import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; 6 | import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; 7 | 8 | import java.time.Instant; 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @DynamoDbBean 14 | public class News { 15 | private String id; 16 | private String title; 17 | private Instant publishedAt; 18 | 19 | @DynamoDbPartitionKey 20 | public String getId() { 21 | return id; 22 | } 23 | 24 | public News(String title) { 25 | this.id = UUID.randomUUID().toString(); 26 | this.title = title; 27 | this.publishedAt = Instant.now(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /news-producer/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | server.port=9080 2 | spring.application.name=news-producer 3 | spring.cloud.aws.dynamodb.endpoint=http://localhost.localstack.cloud:4566 4 | management.endpoints.web.exposure.include=beans,env,health,info,metrics,mappings 5 | management.endpoint.health.show-details=always 6 | springdoc.show-actuator=true 7 | springdoc.swagger-ui.groups-order=DESC 8 | springdoc.swagger-ui.disable-swagger-default-url=true 9 | -------------------------------------------------------------------------------- /news-producer/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ 2 | _ __ _____ _____ _ __ _ __ ___ __| |_ _ ___ ___ _ __ 3 | | '_ \ / _ \ \ /\ / / __|_____| '_ \| '__/ _ \ / _` | | | |/ __/ _ \ '__| 4 | | | | | __/\ V V /\__ \_____| |_) | | | (_) | (_| | |_| | (_| __/ | 5 | |_| |_|\___| \_/\_/ |___/ | .__/|_| \___/ \__,_|\__,_|\___\___|_| 6 | |_| 7 | :: Spring Boot :: ${spring-boot.formatted-version} 8 | -------------------------------------------------------------------------------- /news-producer/src/test/java/com/ivanfranchin/newsproducer/NewsProducerApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.newsproducer; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | @Disabled 8 | @SpringBootTest 9 | class NewsProducerApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package-dynamodb-lambda-function-jar.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo 4 | echo "Packaging jar file" 5 | echo "------------------" 6 | ./mvnw clean package --projects dynamodb-lambda-function -DskipTests 7 | 8 | echo 9 | echo "Copying to dynamodb-lambda-function/shared folder" 10 | echo "-------------------------------------------------" 11 | mkdir -p dynamodb-lambda-function/shared 12 | cp dynamodb-lambda-function/target/dynamodb-lambda-function-java21-aws.jar dynamodb-lambda-function/shared 13 | 14 | echo "Done!" 15 | echo -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.4.3 9 | 10 | 11 | com.ivanfranchin 12 | springboot-aws-localstack-dynamodb-lambda-sns-sqs 13 | 1.0.0 14 | pom 15 | springboot-aws-localstack-dynamodb-lambda-sns-sqs 16 | Demo project for Spring Boot 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 21 32 | 2.30.35 33 | 3.3.0 34 | 35 | 36 | 37 | 38 | 39 | software.amazon.awssdk 40 | bom 41 | ${amazon-awssdk.version} 42 | pom 43 | import 44 | 45 | 46 | io.awspring.cloud 47 | spring-cloud-aws-dependencies 48 | ${spring-cloud-aws.version} 49 | pom 50 | import 51 | 52 | 53 | 54 | 55 | 56 | news-producer 57 | news-consumer 58 | dynamodb-lambda-function 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /remove-docker-images.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker rmi ivanfranchin/news-producer:1.0.0 4 | docker rmi ivanfranchin/news-consumer:1.0.0 5 | --------------------------------------------------------------------------------