├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── README.md ├── build-docker-images.sh ├── docker-compose.yml ├── documentation ├── demo-adding-movie.gif ├── demo-searching-movies.gif ├── project-diagram.excalidraw └── project-diagram.jpeg ├── init-localstack.sh ├── movie-api ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── movieapi │ │ │ ├── MovieApiApplication.java │ │ │ ├── aws │ │ │ ├── AwsProperties.java │ │ │ ├── OpenSearchConfig.java │ │ │ ├── OpenSearchService.java │ │ │ ├── PosterService.java │ │ │ └── S3Service.java │ │ │ ├── config │ │ │ ├── ErrorAttributesConfig.java │ │ │ └── SwaggerConfig.java │ │ │ ├── movie │ │ │ ├── MovieService.java │ │ │ ├── controller │ │ │ │ ├── MoviesUiController.java │ │ │ │ └── dto │ │ │ │ │ └── SearchRequest.java │ │ │ ├── exception │ │ │ │ ├── MovieNotFoundException.java │ │ │ │ ├── OpenSearchServiceException.java │ │ │ │ └── PosterUploaderException.java │ │ │ ├── model │ │ │ │ └── Movie.java │ │ │ └── rest │ │ │ │ ├── MovieController.java │ │ │ │ └── dto │ │ │ │ ├── AddMovieRequest.java │ │ │ │ ├── MovieResponse.java │ │ │ │ └── SearchResponse.java │ │ │ ├── omdb │ │ │ ├── OmdbApiClient.java │ │ │ ├── OmdbApiClientConfig.java │ │ │ ├── OmdbApiProperties.java │ │ │ └── OmdbResponse.java │ │ │ └── runner │ │ │ └── S3InitialUploadRunner.java │ └── resources │ │ ├── application.properties │ │ ├── banner.txt │ │ ├── images │ │ └── not-available.jpg │ │ ├── static │ │ └── app.js │ │ └── templates │ │ ├── movieDetail.html │ │ └── movies.html │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── movieapi │ └── MovieApiApplicationTests.java ├── movie-ui ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── movieui │ │ │ ├── MovieUiApplication.java │ │ │ ├── client │ │ │ ├── MovieApiClient.java │ │ │ └── MovieApiClientConfig.java │ │ │ └── movie │ │ │ ├── MovieService.java │ │ │ ├── MoviesController.java │ │ │ ├── dto │ │ │ ├── SearchRequest.java │ │ │ └── SearchResponse.java │ │ │ └── model │ │ │ └── Movie.java │ └── resources │ │ ├── application.properties │ │ ├── banner.txt │ │ ├── static │ │ └── app.js │ │ └── templates │ │ ├── movieDetail.html │ │ └── movies.html │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── movieui │ └── MovieUiApplicationTests.java ├── mvnw ├── mvnw.cmd ├── opensearch └── movies-settings.json ├── 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 | 3 | target/ 4 | !.mvn/wrapper/maven-wrapper.jar 5 | !**/src/main/**/target/ 6 | !**/src/test/**/target/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | 17 | ### IntelliJ IDEA ### 18 | .idea 19 | *.iws 20 | *.iml 21 | *.ipr 22 | 23 | ### NetBeans ### 24 | /nbproject/private/ 25 | /nbbuild/ 26 | /dist/ 27 | /nbdist/ 28 | /.nb-gradle/ 29 | build/ 30 | !**/src/main/**/build/ 31 | !**/src/test/**/build/ 32 | 33 | ### VS Code ### 34 | .vscode/ 35 | 36 | ### MAC OS ### 37 | *.DS_Store 38 | -------------------------------------------------------------------------------- /.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-opensearch-s3-secretsmanager 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 [`OpenSearch`](https://aws.amazon.com/opensearch-service/), [`S3`](https://aws.amazon.com/s3/), and [`Secrets Manager`](https://aws.amazon.com/secrets-manager/). 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**: Check out the [`springboot-aws-localstack-dynamodb-lambda-sns-sqs`](https://github.com/ivangfr/springboot-aws-localstack-dynamodb-lambda-sns-sqs) repository. In it, we have developed two Spring Boot applications for producing and listening to news updates. We also utilized LocalStack to locally simulate AWS Cloud services 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/). 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 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) 14 | - \[**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) 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 | - ### movie-api 27 | 28 | [`Spring Boot`](https://docs.spring.io/spring-boot/index.html) Java Web application that exposes a REST API and provides a UI for indexing movies. 29 | 30 | It has the following endpoints: 31 | ```text 32 | GET /api/movies/{imdb} 33 | POST /api/movies {"imdb":"...", "title":"...", "posterUrl":"...", "year":"...", "released":"...", "imdbRating":"...", "genre":"...", "runtime":"...", "director":"...", "writer":"...", "actors":"...", "plot":"...", "language":"...", "country":"...", "awards":"..."} 34 | POST /api/movies/{imdb}/uploadPoster 35 | ``` 36 | 37 | The information of the movies, such as `imdb`, `title`, `year`, etc, are stored in `OpenSearch` that is hosted in `LocalStack`. The movie's `poster` is stored in `S3` buckets. 38 | 39 | The `movie-api` has access to [`OMDb API`](https://www.omdbapi.com/) to search and add easily new movies. To make requests to `OMDb API`, an `apiKey` is needed. This key is stored as a secret in `Secrets Manager`. 40 | 41 | - ### movie-ui 42 | 43 | `Spring Boot` Java Web application with a user interface designed for searching movies indexed in `movie-api`. To populate its UI with movie data, `movie-ui` communicates with `movie-api` by making requests to its endpoints. The movie’s poster is retrieved from the `S3` bucket. 44 | 45 | ## Prerequisites 46 | 47 | - [`Java 21`](https://www.oracle.com/java/technologies/downloads/#java21) or higher; 48 | - A containerization tool (e.g., [`Docker`](https://www.docker.com), [`Podman`](https://podman.io), etc.) 49 | - [`OMDb API`](https://www.omdbapi.com/) KEY 50 | 51 | To search for movies in `OMDb API`, we need to obtain an API KEY from `OMDb API`. To do it, access https://www.omdbapi.com/apikey.aspx and follow the steps provided by the website. 52 | 53 | ## Start and Initialize LocalStack 54 | 55 | - In a terminal, make sure you are inside the `springboot-aws-localstack-opensearch-s3-secretsmanager` root folder; 56 | 57 | - Start `LocalStack` Docker container: 58 | ```bash 59 | DEBUG=1 docker compose up -d 60 | ``` 61 | 62 | - \[Optional\] Debug logs are enabled to provide more insights into what is happening. To monitor `localstack` Docker container logs, run the command below: 63 | ```bash 64 | docker logs localstack 65 | ``` 66 | 67 | - Initialize `LocalStack` by running the following script: 68 | ```bash 69 | ./init-localstack.sh 70 | ``` 71 | The script requires `OMDB_API_KEY` as first and unique argument. The script will create: 72 | - a domain for `OpenSearch` as well as the `movies` index using the `movies-settings.json` provided; 73 | - bucket `com.ivanfranchin.movieapi.posters` in `S3`; 74 | - a secret for `OMDB_API_KEY` in `Secrets Manager`. 75 | 76 | ## Running applications with Maven 77 | 78 | - **movie-api** 79 | 80 | In a terminal, inside the `springboot-aws-localstack-opensearch-s3-secretsmanager` root folder, run the following command: 81 | ```bash 82 | export AWS_REGION=eu-west-1 && export AWS_ACCESS_KEY_ID=key && export AWS_SECRET_ACCESS_KEY=secret && \ 83 | ./mvnw clean spring-boot:run --projects movie-api 84 | ``` 85 | 86 | - **movie-ui** 87 | 88 | In another terminal and, inside the `springboot-aws-localstack-opensearch-s3-secretsmanager` root folder, run the command below: 89 | ```bash 90 | ./mvnw clean spring-boot:run --projects movie-ui 91 | ``` 92 | 93 | ## Running applications as Docker container 94 | 95 | - ### Build Docker images 96 | 97 | In a terminal, inside the `springboot-aws-localstack-opensearch-s3-secretsmanager` root folder, run the following script: 98 | ```bash 99 | ./build-docker-images.sh 100 | ``` 101 | 102 | - ### Run Docker containers 103 | 104 | - **movie-api** 105 | 106 | In a terminal, run the following command: 107 | ```bash 108 | docker run --rm --name movie-api -p 9080:9080 \ 109 | -e AWS_REGION=eu-west-1 -e AWS_ACCESS_KEY_ID=key -e AWS_SECRET_ACCESS_KEY=secret \ 110 | --network=springboot-aws-localstack-opensearch-s3-secretsmanager_default \ 111 | ivanfranchin/movie-api:1.0.0 112 | ``` 113 | 114 | - **movie-ui** 115 | 116 | In another terminal, run the command below: 117 | ```bash 118 | docker run --rm --name movie-ui -p 9081:9081 \ 119 | -e MOVIE_API_URL=http://movie-api:9080 \ 120 | --network=springboot-aws-localstack-opensearch-s3-secretsmanager_default \ 121 | ivanfranchin/movie-ui:1.0.0 122 | ``` 123 | 124 | ## Application URL 125 | 126 | | Application | Type | URL | 127 | |-------------|---------|---------------------------------------| 128 | | `movie-api` | Swagger | http://localhost:9080/swagger-ui.html | 129 | | `movie-api` | UI | http://localhost:9080 | 130 | | `movie-ui` | UI | http://localhost:9081 | 131 | 132 | ## Demo 133 | 134 | - **Adding movie**: in the GIF below, we are using `movie-api` to add the movie _"American Pie 2"_ 135 | 136 | ![demo-adding-movie](documentation/demo-adding-movie.gif) 137 | 138 | - **Searching movies**: in the GIF below, we are using `movie-ui` to search for movies 139 | 140 | ![demo-searching-movies](documentation/demo-searching-movies.gif) 141 | 142 | ## Useful Links 143 | 144 | - **OpenSearch** 145 | 146 | Check indexes 147 | ```bash 148 | curl "http://localhost.localstack.cloud:4566/opensearch/eu-west-1/my-domain/_cat/indices?v" 149 | ``` 150 | 151 | Simple search 152 | ```bash 153 | curl "http://localhost.localstack.cloud:4566/opensearch/eu-west-1/my-domain/movies/_search?pretty" 154 | ``` 155 | 156 | ## Shutdown 157 | 158 | - To stop the applications, go to the terminal where they are running and press `Ctrl+C`; 159 | - To stop and remove Docker Compose containers, network, and volumes, go to a terminal and, inside the `springboot-aws-localstack-opensearch-s3-secretsmanager` root folder, run the following command: 160 | ```bash 161 | docker compose down -v 162 | ``` 163 | 164 | ## Cleanup 165 | 166 | To remove the Docker images created by this project, go to a terminal and, inside the `springboot-aws-localstack-opensearch-s3-secretsmanager` root folder, run the script below: 167 | ```bash 168 | ./remove-docker-images.sh 169 | ``` 170 | -------------------------------------------------------------------------------- /build-docker-images.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DOCKER_IMAGE_PREFIX="ivanfranchin" 4 | APP_VERSION="1.0.0" 5 | 6 | MOVIE_API_APP_NAME="movie-api" 7 | MOVIE_UI_APP_NAME="movie-ui" 8 | MOVIE_API_DOCKER_IMAGE_NAME="${DOCKER_IMAGE_PREFIX}/${MOVIE_API_APP_NAME}:${APP_VERSION}" 9 | MOVIE_UI_DOCKER_IMAGE_NAME="${DOCKER_IMAGE_PREFIX}/${MOVIE_UI_APP_NAME}:${APP_VERSION}" 10 | 11 | SKIP_TESTS="true" 12 | 13 | ./mvnw clean spring-boot:build-image \ 14 | --projects "$MOVIE_API_APP_NAME" \ 15 | -DskipTests="$SKIP_TESTS" \ 16 | -Dspring-boot.build-image.imageName="$MOVIE_API_DOCKER_IMAGE_NAME" 17 | 18 | ./mvnw clean spring-boot:build-image \ 19 | --projects "$MOVIE_UI_APP_NAME" \ 20 | -DskipTests="$SKIP_TESTS" \ 21 | -Dspring-boot.build-image.imageName="$MOVIE_UI_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 | - 'OPENSEARCH_ENDPOINT_STRATEGY=path' 11 | - 'LOCALSTACK_HOSTNAME=localhost.localstack.cloud' # set this env var to expose localstack to other containers 12 | - 'AWS_ACCESS_KEY_ID=key' 13 | - 'AWS_SECRET_ACCESS_KEY=secret' 14 | - 'AWS_DEFAULT_REGION=eu-west-1' 15 | - 'SERVICES=opensearch,s3,secretsmanager' 16 | # --- 17 | - 'DEBUG=${DEBUG-}' 18 | volumes: 19 | - '$PWD/tmp/localstack:/var/lib/localstack' 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-adding-movie.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-aws-localstack-opensearch-s3-secretsmanager/e7acae4dcdcc512dff3f666eae0909ae669d822c/documentation/demo-adding-movie.gif -------------------------------------------------------------------------------- /documentation/demo-searching-movies.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-aws-localstack-opensearch-s3-secretsmanager/e7acae4dcdcc512dff3f666eae0909ae669d822c/documentation/demo-searching-movies.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": 1826, 9 | "versionNonce": 789826570, 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": -165.76353454589847, 19 | "y": 218.30183520291942, 20 | "strokeColor": "#000000", 21 | "backgroundColor": "#fd7e14", 22 | "width": 209.18356323242188, 23 | "height": 99.67071533203125, 24 | "seed": 1701897878, 25 | "groupIds": [], 26 | "roundness": { 27 | "type": 3 28 | }, 29 | "boundElements": [ 30 | { 31 | "type": "text", 32 | "id": "2X2Ld8TDu-NPJPkdyJl5g" 33 | }, 34 | { 35 | "id": "turVDsRIFdQK33RASnQwn", 36 | "type": "arrow" 37 | }, 38 | { 39 | "id": "SmXhx-l2VrR_q4iduBnRR", 40 | "type": "arrow" 41 | }, 42 | { 43 | "id": "klP8oLqpyYNCFYUF0yHAF", 44 | "type": "arrow" 45 | } 46 | ], 47 | "updated": 1682459650653, 48 | "link": null, 49 | "locked": false 50 | }, 51 | { 52 | "type": "text", 53 | "version": 1844, 54 | "versionNonce": 1441157526, 55 | "isDeleted": false, 56 | "id": "2X2Ld8TDu-NPJPkdyJl5g", 57 | "fillStyle": "hachure", 58 | "strokeWidth": 1, 59 | "strokeStyle": "solid", 60 | "roughness": 1, 61 | "opacity": 100, 62 | "angle": 0, 63 | "x": -112.60772705078128, 64 | "y": 251.33719286893503, 65 | "strokeColor": "#000000", 66 | "backgroundColor": "transparent", 67 | "width": 102.8719482421875, 68 | "height": 33.6, 69 | "seed": 200323745, 70 | "groupIds": [], 71 | "roundness": null, 72 | "boundElements": [], 73 | "updated": 1682459650653, 74 | "link": null, 75 | "locked": false, 76 | "fontSize": 28, 77 | "fontFamily": 1, 78 | "text": "movie-ui", 79 | "textAlign": "center", 80 | "verticalAlign": "middle", 81 | "containerId": "htH4DvpAlw_lK0WCfkn_y", 82 | "originalText": "movie-ui", 83 | "lineHeight": 1.2, 84 | "baseline": 24 85 | }, 86 | { 87 | "type": "rectangle", 88 | "version": 1520, 89 | "versionNonce": 529644234, 90 | "isDeleted": false, 91 | "id": "NKmNZxYxWMCKh3prRiPwX", 92 | "fillStyle": "hachure", 93 | "strokeWidth": 1, 94 | "strokeStyle": "solid", 95 | "roughness": 1, 96 | "opacity": 100, 97 | "angle": 0, 98 | "x": -163.74735802178012, 99 | "y": -14.438166370318783, 100 | "strokeColor": "#000000", 101 | "backgroundColor": "#4c6ef5", 102 | "width": 209.18356323242188, 103 | "height": 99.67071533203125, 104 | "seed": 1929618390, 105 | "groupIds": [], 106 | "roundness": { 107 | "type": 3 108 | }, 109 | "boundElements": [ 110 | { 111 | "type": "text", 112 | "id": "GrVT2PZuYu1cRv3sXwFoI" 113 | }, 114 | { 115 | "id": "s75NFoH7QND6gLrkoAHQN", 116 | "type": "arrow" 117 | }, 118 | { 119 | "id": "turVDsRIFdQK33RASnQwn", 120 | "type": "arrow" 121 | }, 122 | { 123 | "id": "O4810p3r-IFjKEoCLEU6O", 124 | "type": "arrow" 125 | }, 126 | { 127 | "id": "DZpByHGRVUSt0NLO63Wcb", 128 | "type": "arrow" 129 | }, 130 | { 131 | "id": "H7fJvZ3SSKrOzTdLYAcrA", 132 | "type": "arrow" 133 | } 134 | ], 135 | "updated": 1682459650653, 136 | "link": null, 137 | "locked": false 138 | }, 139 | { 140 | "type": "text", 141 | "version": 423, 142 | "versionNonce": 663989974, 143 | "isDeleted": false, 144 | "id": "GrVT2PZuYu1cRv3sXwFoI", 145 | "fillStyle": "hachure", 146 | "strokeWidth": 1, 147 | "strokeStyle": "solid", 148 | "roughness": 0, 149 | "opacity": 100, 150 | "angle": 0, 151 | "x": -118.87954491142855, 152 | "y": 18.597191295696845, 153 | "strokeColor": "#000000", 154 | "backgroundColor": "transparent", 155 | "width": 119.44793701171875, 156 | "height": 33.6, 157 | "seed": 294979727, 158 | "groupIds": [], 159 | "roundness": null, 160 | "boundElements": [], 161 | "updated": 1682459650653, 162 | "link": null, 163 | "locked": false, 164 | "fontSize": 28, 165 | "fontFamily": 1, 166 | "text": "movie-api", 167 | "textAlign": "center", 168 | "verticalAlign": "middle", 169 | "containerId": "NKmNZxYxWMCKh3prRiPwX", 170 | "originalText": "movie-api", 171 | "lineHeight": 1.2, 172 | "baseline": 24 173 | }, 174 | { 175 | "type": "ellipse", 176 | "version": 1751, 177 | "versionNonce": 266588554, 178 | "isDeleted": false, 179 | "id": "zYllgBlgP7S7-phqNnnEr", 180 | "fillStyle": "hachure", 181 | "strokeWidth": 2, 182 | "strokeStyle": "solid", 183 | "roughness": 1, 184 | "opacity": 100, 185 | "angle": 0, 186 | "x": -12.786649403615655, 187 | "y": -155.8907459738384, 188 | "strokeColor": "#000000", 189 | "backgroundColor": "transparent", 190 | "width": 26.930389404296875, 191 | "height": 27.545562744140625, 192 | "seed": 1372371222, 193 | "groupIds": [ 194 | "4D1ojplACrlVIaNZ7P0FH" 195 | ], 196 | "roundness": { 197 | "type": 2 198 | }, 199 | "boundElements": [], 200 | "updated": 1682459650653, 201 | "link": null, 202 | "locked": false 203 | }, 204 | { 205 | "type": "line", 206 | "version": 1770, 207 | "versionNonce": 774071318, 208 | "isDeleted": false, 209 | "id": "iW_3iMfYwsgECYYtnEK33", 210 | "fillStyle": "hachure", 211 | "strokeWidth": 2, 212 | "strokeStyle": "solid", 213 | "roughness": 1, 214 | "opacity": 100, 215 | "angle": 0, 216 | "x": -1.348813710256735, 217 | "y": -127.89010510469777, 218 | "strokeColor": "#000000", 219 | "backgroundColor": "transparent", 220 | "width": 0.473419189453125, 221 | "height": 40.3687744140625, 222 | "seed": 960570966, 223 | "groupIds": [ 224 | "4D1ojplACrlVIaNZ7P0FH" 225 | ], 226 | "roundness": { 227 | "type": 2 228 | }, 229 | "boundElements": [], 230 | "updated": 1682459650653, 231 | "link": null, 232 | "locked": false, 233 | "startBinding": null, 234 | "endBinding": null, 235 | "lastCommittedPoint": null, 236 | "startArrowhead": null, 237 | "endArrowhead": null, 238 | "points": [ 239 | [ 240 | 0, 241 | 0 242 | ], 243 | [ 244 | -0.473419189453125, 245 | 40.3687744140625 246 | ] 247 | ] 248 | }, 249 | { 250 | "type": "line", 251 | "version": 1721, 252 | "versionNonce": 1128599626, 253 | "isDeleted": false, 254 | "id": "8JTNvN86yjteIVYunsqxA", 255 | "fillStyle": "hachure", 256 | "strokeWidth": 2, 257 | "strokeStyle": "solid", 258 | "roughness": 1, 259 | "opacity": 100, 260 | "angle": 0, 261 | "x": -1.64431541924111, 262 | "y": -86.08645520235402, 263 | "strokeColor": "#000000", 264 | "backgroundColor": "transparent", 265 | "width": 17.21380615234375, 266 | "height": 33.91400146484375, 267 | "seed": 708176790, 268 | "groupIds": [ 269 | "4D1ojplACrlVIaNZ7P0FH" 270 | ], 271 | "roundness": { 272 | "type": 2 273 | }, 274 | "boundElements": [], 275 | "updated": 1682459650653, 276 | "link": null, 277 | "locked": false, 278 | "startBinding": null, 279 | "endBinding": null, 280 | "lastCommittedPoint": null, 281 | "startArrowhead": null, 282 | "endArrowhead": null, 283 | "points": [ 284 | [ 285 | 0, 286 | 0 287 | ], 288 | [ 289 | -17.21380615234375, 290 | 33.91400146484375 291 | ] 292 | ] 293 | }, 294 | { 295 | "type": "line", 296 | "version": 1740, 297 | "versionNonce": 1267364182, 298 | "isDeleted": false, 299 | "id": "hMliKg9HCYu-lEplUg1Y6", 300 | "fillStyle": "hachure", 301 | "strokeWidth": 2, 302 | "strokeStyle": "solid", 303 | "roughness": 1, 304 | "opacity": 100, 305 | "angle": 0, 306 | "x": -1.52847069267861, 307 | "y": -85.96627697969777, 308 | "strokeColor": "#000000", 309 | "backgroundColor": "transparent", 310 | "width": 12.9422607421875, 311 | "height": 35.16510009765625, 312 | "seed": 1121978582, 313 | "groupIds": [ 314 | "4D1ojplACrlVIaNZ7P0FH" 315 | ], 316 | "roundness": { 317 | "type": 2 318 | }, 319 | "boundElements": [], 320 | "updated": 1682459650653, 321 | "link": null, 322 | "locked": false, 323 | "startBinding": null, 324 | "endBinding": null, 325 | "lastCommittedPoint": null, 326 | "startArrowhead": null, 327 | "endArrowhead": null, 328 | "points": [ 329 | [ 330 | 0, 331 | 0 332 | ], 333 | [ 334 | 12.9422607421875, 335 | 35.16510009765625 336 | ] 337 | ] 338 | }, 339 | { 340 | "type": "line", 341 | "version": 1756, 342 | "versionNonce": 1569748746, 343 | "isDeleted": false, 344 | "id": "pnA0DA25tJxDKgaSQnlkY", 345 | "fillStyle": "hachure", 346 | "strokeWidth": 2, 347 | "strokeStyle": "solid", 348 | "roughness": 1, 349 | "opacity": 100, 350 | "angle": 0, 351 | "x": -0.332639393850485, 352 | "y": -110.39211926485402, 353 | "strokeColor": "#000000", 354 | "backgroundColor": "transparent", 355 | "width": 29.445220947265625, 356 | "height": 20.990234375, 357 | "seed": 315915798, 358 | "groupIds": [ 359 | "4D1ojplACrlVIaNZ7P0FH" 360 | ], 361 | "roundness": { 362 | "type": 2 363 | }, 364 | "boundElements": [], 365 | "updated": 1682459650653, 366 | "link": null, 367 | "locked": false, 368 | "startBinding": null, 369 | "endBinding": null, 370 | "lastCommittedPoint": null, 371 | "startArrowhead": null, 372 | "endArrowhead": null, 373 | "points": [ 374 | [ 375 | 0, 376 | 0 377 | ], 378 | [ 379 | 29.445220947265625, 380 | -20.990234375 381 | ] 382 | ] 383 | }, 384 | { 385 | "type": "line", 386 | "version": 1795, 387 | "versionNonce": 1528682134, 388 | "isDeleted": false, 389 | "id": "HhvpXqS4JXS1iu_1aiVep", 390 | "fillStyle": "hachure", 391 | "strokeWidth": 2, 392 | "strokeStyle": "solid", 393 | "roughness": 1, 394 | "opacity": 100, 395 | "angle": 0, 396 | "x": -1.22353905205361, 397 | "y": -111.12228283907277, 398 | "strokeColor": "#000000", 399 | "backgroundColor": "transparent", 400 | "width": 25.4169921875, 401 | "height": 9.85821533203125, 402 | "seed": 1862814550, 403 | "groupIds": [ 404 | "4D1ojplACrlVIaNZ7P0FH" 405 | ], 406 | "roundness": { 407 | "type": 2 408 | }, 409 | "boundElements": [], 410 | "updated": 1682459650653, 411 | "link": null, 412 | "locked": false, 413 | "startBinding": null, 414 | "endBinding": null, 415 | "lastCommittedPoint": null, 416 | "startArrowhead": null, 417 | "endArrowhead": null, 418 | "points": [ 419 | [ 420 | 0, 421 | 0 422 | ], 423 | [ 424 | -25.4169921875, 425 | -9.85821533203125 426 | ] 427 | ] 428 | }, 429 | { 430 | "type": "text", 431 | "version": 1821, 432 | "versionNonce": 1329661386, 433 | "isDeleted": false, 434 | "id": "T0m-48lm7wA_Uw99BXSmk", 435 | "fillStyle": "hachure", 436 | "strokeWidth": 2, 437 | "strokeStyle": "solid", 438 | "roughness": 1, 439 | "opacity": 100, 440 | "angle": 0, 441 | "x": -23.43243187431878, 442 | "y": -187.8361805441509, 443 | "strokeColor": "#000000", 444 | "backgroundColor": "transparent", 445 | "width": 44.679962158203125, 446 | "height": 24, 447 | "seed": 1171288214, 448 | "groupIds": [ 449 | "4D1ojplACrlVIaNZ7P0FH" 450 | ], 451 | "roundness": null, 452 | "boundElements": [], 453 | "updated": 1682459650653, 454 | "link": null, 455 | "locked": false, 456 | "fontSize": 20, 457 | "fontFamily": 1, 458 | "text": "User", 459 | "textAlign": "left", 460 | "verticalAlign": "top", 461 | "containerId": null, 462 | "originalText": "User", 463 | "lineHeight": 1.2, 464 | "baseline": 17 465 | }, 466 | { 467 | "type": "rectangle", 468 | "version": 457, 469 | "versionNonce": 93492182, 470 | "isDeleted": false, 471 | "id": "0tFsB5XswcFk43y4cu2nN", 472 | "fillStyle": "hachure", 473 | "strokeWidth": 1, 474 | "strokeStyle": "solid", 475 | "roughness": 1, 476 | "opacity": 100, 477 | "angle": 0, 478 | "x": 147.09004662573443, 479 | "y": -242.85677990938527, 480 | "strokeColor": "#000000", 481 | "backgroundColor": "#ced4da", 482 | "width": 884.5347290039062, 483 | "height": 638.3636932373047, 484 | "seed": 1927409110, 485 | "groupIds": [], 486 | "roundness": { 487 | "type": 3 488 | }, 489 | "boundElements": [], 490 | "updated": 1682459650653, 491 | "link": null, 492 | "locked": false 493 | }, 494 | { 495 | "type": "text", 496 | "version": 328, 497 | "versionNonce": 1097972874, 498 | "isDeleted": false, 499 | "id": "kx1ruLDwJgOJo3ggUxasx", 500 | "fillStyle": "hachure", 501 | "strokeWidth": 1, 502 | "strokeStyle": "solid", 503 | "roughness": 1, 504 | "opacity": 100, 505 | "angle": 0, 506 | "x": 483.39619896948443, 507 | "y": -224.94938550020558, 508 | "strokeColor": "#000000", 509 | "backgroundColor": "#ced4da", 510 | "width": 247.37991333007812, 511 | "height": 33.6, 512 | "seed": 324125462, 513 | "groupIds": [], 514 | "roundness": null, 515 | "boundElements": [], 516 | "updated": 1682459650653, 517 | "link": null, 518 | "locked": false, 519 | "fontSize": 28, 520 | "fontFamily": 1, 521 | "text": "AWS - LocalStack", 522 | "textAlign": "left", 523 | "verticalAlign": "top", 524 | "containerId": null, 525 | "originalText": "AWS - LocalStack", 526 | "lineHeight": 1.2, 527 | "baseline": 24 528 | }, 529 | { 530 | "type": "text", 531 | "version": 288, 532 | "versionNonce": 352168214, 533 | "isDeleted": false, 534 | "id": "f13FJlYghROUhu-phVfdC", 535 | "fillStyle": "hachure", 536 | "strokeWidth": 1, 537 | "strokeStyle": "solid", 538 | "roughness": 1, 539 | "opacity": 100, 540 | "angle": 0, 541 | "x": 422.95747826635943, 542 | "y": -182.64837842012747, 543 | "strokeColor": "#000000", 544 | "backgroundColor": "#ced4da", 545 | "width": 371.2196350097656, 546 | "height": 24, 547 | "seed": 1502933078, 548 | "groupIds": [], 549 | "roundness": null, 550 | "boundElements": [], 551 | "updated": 1682459650653, 552 | "link": null, 553 | "locked": false, 554 | "fontSize": 20, 555 | "fontFamily": 1, 556 | "text": "http://localhost.localstack.cloud:4566", 557 | "textAlign": "left", 558 | "verticalAlign": "top", 559 | "containerId": null, 560 | "originalText": "http://localhost.localstack.cloud:4566", 561 | "lineHeight": 1.2, 562 | "baseline": 17 563 | }, 564 | { 565 | "type": "arrow", 566 | "version": 860, 567 | "versionNonce": 692413846, 568 | "isDeleted": false, 569 | "id": "s75NFoH7QND6gLrkoAHQN", 570 | "fillStyle": "solid", 571 | "strokeWidth": 1, 572 | "strokeStyle": "solid", 573 | "roughness": 1, 574 | "opacity": 100, 575 | "angle": 0, 576 | "x": -31.06392263960393, 577 | "y": -94.88420742361072, 578 | "strokeColor": "#000000", 579 | "backgroundColor": "#7950f2", 580 | "width": 48.103090450249795, 581 | "height": 71.88839721679688, 582 | "seed": 1635808662, 583 | "groupIds": [], 584 | "roundness": { 585 | "type": 2 586 | }, 587 | "boundElements": [], 588 | "updated": 1682459655582, 589 | "link": null, 590 | "locked": false, 591 | "startBinding": null, 592 | "endBinding": { 593 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 594 | "focus": -0.33854547637306215, 595 | "gap": 8.55764383649506 596 | }, 597 | "lastCommittedPoint": null, 598 | "startArrowhead": null, 599 | "endArrowhead": "arrow", 600 | "points": [ 601 | [ 602 | 0, 603 | 0 604 | ], 605 | [ 606 | -29.41021728515625, 607 | 21.48095703125 608 | ], 609 | [ 610 | -48.103090450249795, 611 | 71.88839721679688 612 | ] 613 | ] 614 | }, 615 | { 616 | "type": "arrow", 617 | "version": 1070, 618 | "versionNonce": 73262794, 619 | "isDeleted": false, 620 | "id": "turVDsRIFdQK33RASnQwn", 621 | "fillStyle": "solid", 622 | "strokeWidth": 1, 623 | "strokeStyle": "solid", 624 | "roughness": 1, 625 | "opacity": 100, 626 | "angle": 0, 627 | "x": -66.49826558065799, 628 | "y": 210.72647983224866, 629 | "strokeColor": "#000000", 630 | "backgroundColor": "transparent", 631 | "width": 2.7250184138174625, 632 | "height": 116.73037719726562, 633 | "seed": 71914198, 634 | "groupIds": [], 635 | "roundness": { 636 | "type": 2 637 | }, 638 | "boundElements": [], 639 | "updated": 1682459655582, 640 | "link": null, 641 | "locked": false, 642 | "startBinding": { 643 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 644 | "focus": -0.06481623356844345, 645 | "gap": 7.575355370670749 646 | }, 647 | "endBinding": { 648 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 649 | "focus": 0.032644928282001114, 650 | "gap": 8.763553673270565 651 | }, 652 | "lastCommittedPoint": null, 653 | "startArrowhead": "arrow", 654 | "endArrowhead": "arrow", 655 | "points": [ 656 | [ 657 | 0, 658 | 0 659 | ], 660 | [ 661 | 1.5413070523821801, 662 | -57.4840087890625 663 | ], 664 | [ 665 | 2.7250184138174625, 666 | -116.73037719726562 667 | ] 668 | ] 669 | }, 670 | { 671 | "type": "arrow", 672 | "version": 414, 673 | "versionNonce": 1710244566, 674 | "isDeleted": false, 675 | "id": "SmXhx-l2VrR_q4iduBnRR", 676 | "fillStyle": "solid", 677 | "strokeWidth": 1, 678 | "strokeStyle": "solid", 679 | "roughness": 1, 680 | "opacity": 100, 681 | "angle": 0, 682 | "x": -35.675037141557056, 683 | "y": -118.5958564635327, 684 | "strokeColor": "#000000", 685 | "backgroundColor": "transparent", 686 | "width": 137.85205078125, 687 | "height": 327.4667663574219, 688 | "seed": 746883094, 689 | "groupIds": [], 690 | "roundness": { 691 | "type": 2 692 | }, 693 | "boundElements": [], 694 | "updated": 1682459655582, 695 | "link": null, 696 | "locked": false, 697 | "startBinding": null, 698 | "endBinding": { 699 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 700 | "focus": -0.5442781631757997, 701 | "gap": 9.43092530903023 702 | }, 703 | "lastCommittedPoint": null, 704 | "startArrowhead": null, 705 | "endArrowhead": "arrow", 706 | "points": [ 707 | [ 708 | 0, 709 | 0 710 | ], 711 | [ 712 | -137.85205078125, 713 | 107.91061401367188 714 | ], 715 | [ 716 | -98.07510375976562, 717 | 327.4667663574219 718 | ] 719 | ] 720 | }, 721 | { 722 | "type": "rectangle", 723 | "version": 1811, 724 | "versionNonce": 703257494, 725 | "isDeleted": false, 726 | "id": "uzZEmVQIV4PY6hbv8r60K", 727 | "fillStyle": "hachure", 728 | "strokeWidth": 1, 729 | "strokeStyle": "solid", 730 | "roughness": 1, 731 | "opacity": 100, 732 | "angle": 0, 733 | "x": 172.08687246501603, 734 | "y": -145.58661513054946, 735 | "strokeColor": "#000000", 736 | "backgroundColor": "#be4bdb", 737 | "width": 842, 738 | "height": 154, 739 | "seed": 707204438, 740 | "groupIds": [ 741 | "MapYJ1-CdX301p9xLVArh" 742 | ], 743 | "roundness": { 744 | "type": 3 745 | }, 746 | "boundElements": [ 747 | { 748 | "id": "s75NFoH7QND6gLrkoAHQN", 749 | "type": "arrow" 750 | }, 751 | { 752 | "id": "turVDsRIFdQK33RASnQwn", 753 | "type": "arrow" 754 | }, 755 | { 756 | "type": "text", 757 | "id": "hPdtL4tI7L7xr0yJOP8r1" 758 | }, 759 | { 760 | "id": "O4810p3r-IFjKEoCLEU6O", 761 | "type": "arrow" 762 | } 763 | ], 764 | "updated": 1682459650654, 765 | "link": null, 766 | "locked": false 767 | }, 768 | { 769 | "type": "text", 770 | "version": 823, 771 | "versionNonce": 1668468938, 772 | "isDeleted": false, 773 | "id": "hPdtL4tI7L7xr0yJOP8r1", 774 | "fillStyle": "hachure", 775 | "strokeWidth": 1, 776 | "strokeStyle": "solid", 777 | "roughness": 1, 778 | "opacity": 100, 779 | "angle": 0, 780 | "x": 515.0374721354262, 781 | "y": -118.98661513054947, 782 | "strokeColor": "#000000", 783 | "backgroundColor": "#be4bdb", 784 | "width": 156.0988006591797, 785 | "height": 100.80000000000001, 786 | "seed": 109443734, 787 | "groupIds": [ 788 | "MapYJ1-CdX301p9xLVArh" 789 | ], 790 | "roundness": null, 791 | "boundElements": [], 792 | "updated": 1682459650654, 793 | "link": null, 794 | "locked": false, 795 | "fontSize": 28.48896579156703, 796 | "fontFamily": 1, 797 | "text": "OpenSearch\n\n", 798 | "textAlign": "center", 799 | "verticalAlign": "middle", 800 | "containerId": "uzZEmVQIV4PY6hbv8r60K", 801 | "originalText": "OpenSearch\n\n", 802 | "lineHeight": 1.1794039926133746, 803 | "baseline": 91 804 | }, 805 | { 806 | "type": "text", 807 | "version": 297, 808 | "versionNonce": 1584855254, 809 | "isDeleted": false, 810 | "id": "Bb7A0nF3hzvCYyBTEOQAp", 811 | "fillStyle": "solid", 812 | "strokeWidth": 2, 813 | "strokeStyle": "solid", 814 | "roughness": 1, 815 | "opacity": 100, 816 | "angle": 0, 817 | "x": 185.88142846167193, 818 | "y": -70.4861591818462, 819 | "strokeColor": "#000000", 820 | "backgroundColor": "#be4bdb", 821 | "width": 816.8793334960938, 822 | "height": 24, 823 | "seed": 1482045398, 824 | "groupIds": [ 825 | "MapYJ1-CdX301p9xLVArh" 826 | ], 827 | "roundness": null, 828 | "boundElements": [], 829 | "updated": 1682459650654, 830 | "link": null, 831 | "locked": false, 832 | "fontSize": 20, 833 | "fontFamily": 1, 834 | "text": "http://localhost.localstack.cloud:4566/opensearch/eu-west-1/my-domain/", 835 | "textAlign": "left", 836 | "verticalAlign": "top", 837 | "containerId": null, 838 | "originalText": "http://localhost.localstack.cloud:4566/opensearch/eu-west-1/my-domain/", 839 | "lineHeight": 1.2, 840 | "baseline": 17 841 | }, 842 | { 843 | "type": "text", 844 | "version": 314, 845 | "versionNonce": 590489482, 846 | "isDeleted": false, 847 | "id": "JVCgeSYHRmnYhbqd_dNpw", 848 | "fillStyle": "solid", 849 | "strokeWidth": 2, 850 | "strokeStyle": "solid", 851 | "roughness": 1, 852 | "opacity": 100, 853 | "angle": 0, 854 | "x": 519.3772704416524, 855 | "y": -30.801710939658705, 856 | "strokeColor": "#000000", 857 | "backgroundColor": "#be4bdb", 858 | "width": 150.81985473632812, 859 | "height": 24, 860 | "seed": 397200662, 861 | "groupIds": [ 862 | "MapYJ1-CdX301p9xLVArh" 863 | ], 864 | "roundness": null, 865 | "boundElements": [], 866 | "updated": 1682459650654, 867 | "link": null, 868 | "locked": false, 869 | "fontSize": 20, 870 | "fontFamily": 1, 871 | "text": "Indexes: movies", 872 | "textAlign": "left", 873 | "verticalAlign": "top", 874 | "containerId": null, 875 | "originalText": "Indexes: movies", 876 | "lineHeight": 1.2, 877 | "baseline": 17 878 | }, 879 | { 880 | "type": "rectangle", 881 | "version": 1888, 882 | "versionNonce": 2114809366, 883 | "isDeleted": false, 884 | "id": "HWLjFHXE-5HDxDlkKzLeV", 885 | "fillStyle": "hachure", 886 | "strokeWidth": 1, 887 | "strokeStyle": "solid", 888 | "roughness": 1, 889 | "opacity": 100, 890 | "angle": 0, 891 | "x": 173.48177025854693, 892 | "y": 28.06953539823192, 893 | "strokeColor": "#000000", 894 | "backgroundColor": "#82c91e", 895 | "width": 842, 896 | "height": 154, 897 | "seed": 1912519254, 898 | "groupIds": [ 899 | "gwFYxcaLBsQyJJSe0wB5T" 900 | ], 901 | "roundness": { 902 | "type": 3 903 | }, 904 | "boundElements": [ 905 | { 906 | "id": "s75NFoH7QND6gLrkoAHQN", 907 | "type": "arrow" 908 | }, 909 | { 910 | "id": "turVDsRIFdQK33RASnQwn", 911 | "type": "arrow" 912 | }, 913 | { 914 | "type": "text", 915 | "id": "Bp2gE6S1cWG5FVUBnv4MJ" 916 | }, 917 | { 918 | "id": "DZpByHGRVUSt0NLO63Wcb", 919 | "type": "arrow" 920 | }, 921 | { 922 | "id": "klP8oLqpyYNCFYUF0yHAF", 923 | "type": "arrow" 924 | } 925 | ], 926 | "updated": 1682459650654, 927 | "link": null, 928 | "locked": false 929 | }, 930 | { 931 | "type": "text", 932 | "version": 908, 933 | "versionNonce": 1180214858, 934 | "isDeleted": false, 935 | "id": "Bp2gE6S1cWG5FVUBnv4MJ", 936 | "fillStyle": "hachure", 937 | "strokeWidth": 1, 938 | "strokeStyle": "solid", 939 | "roughness": 1, 940 | "opacity": 100, 941 | "angle": 0, 942 | "x": 576.1264235788594, 943 | "y": 54.669535398231915, 944 | "strokeColor": "#000000", 945 | "backgroundColor": "#82c91e", 946 | "width": 36.710693359375, 947 | "height": 100.80000000000001, 948 | "seed": 505479062, 949 | "groupIds": [ 950 | "gwFYxcaLBsQyJJSe0wB5T" 951 | ], 952 | "roundness": null, 953 | "boundElements": [], 954 | "updated": 1682459650654, 955 | "link": null, 956 | "locked": false, 957 | "fontSize": 28.48896579156703, 958 | "fontFamily": 1, 959 | "text": "S3\n\n", 960 | "textAlign": "center", 961 | "verticalAlign": "middle", 962 | "containerId": "HWLjFHXE-5HDxDlkKzLeV", 963 | "originalText": "S3\n\n", 964 | "lineHeight": 1.1794039926133746, 965 | "baseline": 91 966 | }, 967 | { 968 | "type": "text", 969 | "version": 528, 970 | "versionNonce": 1705196374, 971 | "isDeleted": false, 972 | "id": "1hVaSqtkvtWm1HWYoAqkw", 973 | "fillStyle": "solid", 974 | "strokeWidth": 2, 975 | "strokeStyle": "solid", 976 | "roughness": 1, 977 | "opacity": 100, 978 | "angle": 0, 979 | "x": 290.4338579934841, 980 | "y": 100.95649037037268, 981 | "strokeColor": "#000000", 982 | "backgroundColor": "#82c91e", 983 | "width": 628.2994995117188, 984 | "height": 24, 985 | "seed": 810065110, 986 | "groupIds": [ 987 | "gwFYxcaLBsQyJJSe0wB5T" 988 | ], 989 | "roundness": null, 990 | "boundElements": [], 991 | "updated": 1682459650654, 992 | "link": null, 993 | "locked": false, 994 | "fontSize": 20, 995 | "fontFamily": 1, 996 | "text": "http://localhost.localstack.cloud:4566//", 997 | "textAlign": "left", 998 | "verticalAlign": "top", 999 | "containerId": null, 1000 | "originalText": "http://localhost.localstack.cloud:4566//", 1001 | "lineHeight": 1.2, 1002 | "baseline": 17 1003 | }, 1004 | { 1005 | "type": "text", 1006 | "version": 539, 1007 | "versionNonce": 633425162, 1008 | "isDeleted": false, 1009 | "id": "VD4KwAs06WYEC2wsAf94M", 1010 | "fillStyle": "solid", 1011 | "strokeWidth": 2, 1012 | "strokeStyle": "solid", 1013 | "roughness": 1, 1014 | "opacity": 100, 1015 | "angle": 0, 1016 | "x": 411.49592311799574, 1017 | "y": 141.29645619068518, 1018 | "strokeColor": "#000000", 1019 | "backgroundColor": "#82c91e", 1020 | "width": 406.599609375, 1021 | "height": 24, 1022 | "seed": 1846186518, 1023 | "groupIds": [ 1024 | "gwFYxcaLBsQyJJSe0wB5T" 1025 | ], 1026 | "roundness": null, 1027 | "boundElements": [], 1028 | "updated": 1682459650654, 1029 | "link": null, 1030 | "locked": false, 1031 | "fontSize": 20, 1032 | "fontFamily": 1, 1033 | "text": "Buckets: com.ivanfranchin.movieapi.posters", 1034 | "textAlign": "left", 1035 | "verticalAlign": "top", 1036 | "containerId": null, 1037 | "originalText": "Buckets: com.ivanfranchin.movieapi.posters", 1038 | "lineHeight": 1.2, 1039 | "baseline": 17 1040 | }, 1041 | { 1042 | "type": "rectangle", 1043 | "version": 1945, 1044 | "versionNonce": 193814678, 1045 | "isDeleted": false, 1046 | "id": "qpCMVexxUXjZVbfsYRYZc", 1047 | "fillStyle": "hachure", 1048 | "strokeWidth": 1, 1049 | "strokeStyle": "solid", 1050 | "roughness": 1, 1051 | "opacity": 100, 1052 | "angle": 0, 1053 | "x": 173.23744652807818, 1054 | "y": 211.60829272245067, 1055 | "strokeColor": "#000000", 1056 | "backgroundColor": "#fa5252", 1057 | "width": 842, 1058 | "height": 154, 1059 | "seed": 576446294, 1060 | "groupIds": [ 1061 | "Agmx0PzmSvBRtRl69QbCW" 1062 | ], 1063 | "roundness": { 1064 | "type": 3 1065 | }, 1066 | "boundElements": [ 1067 | { 1068 | "id": "s75NFoH7QND6gLrkoAHQN", 1069 | "type": "arrow" 1070 | }, 1071 | { 1072 | "id": "turVDsRIFdQK33RASnQwn", 1073 | "type": "arrow" 1074 | }, 1075 | { 1076 | "type": "text", 1077 | "id": "4QuAecqZ-BBKZE_GntsJX" 1078 | }, 1079 | { 1080 | "id": "H7fJvZ3SSKrOzTdLYAcrA", 1081 | "type": "arrow" 1082 | } 1083 | ], 1084 | "updated": 1682459650654, 1085 | "link": null, 1086 | "locked": false 1087 | }, 1088 | { 1089 | "type": "text", 1090 | "version": 985, 1091 | "versionNonce": 1189551050, 1092 | "isDeleted": false, 1093 | "id": "4QuAecqZ-BBKZE_GntsJX", 1094 | "fillStyle": "hachure", 1095 | "strokeWidth": 1, 1096 | "strokeStyle": "solid", 1097 | "roughness": 1, 1098 | "opacity": 100, 1099 | "angle": 0, 1100 | "x": 476.2306182199727, 1101 | "y": 238.20829272245066, 1102 | "strokeColor": "#000000", 1103 | "backgroundColor": "#fa5252", 1104 | "width": 236.01365661621094, 1105 | "height": 100.80000000000001, 1106 | "seed": 1905362070, 1107 | "groupIds": [ 1108 | "Agmx0PzmSvBRtRl69QbCW" 1109 | ], 1110 | "roundness": null, 1111 | "boundElements": [], 1112 | "updated": 1682459650654, 1113 | "link": null, 1114 | "locked": false, 1115 | "fontSize": 28.48896579156703, 1116 | "fontFamily": 1, 1117 | "text": "Secrets Manager\n\n", 1118 | "textAlign": "center", 1119 | "verticalAlign": "middle", 1120 | "containerId": "qpCMVexxUXjZVbfsYRYZc", 1121 | "originalText": "Secrets Manager\n\n", 1122 | "lineHeight": 1.1794039926133746, 1123 | "baseline": 91 1124 | }, 1125 | { 1126 | "type": "text", 1127 | "version": 641, 1128 | "versionNonce": 1400954326, 1129 | "isDeleted": false, 1130 | "id": "7imI1MtvJafZFisYsMq8e", 1131 | "fillStyle": "solid", 1132 | "strokeWidth": 2, 1133 | "strokeStyle": "solid", 1134 | "roughness": 1, 1135 | "opacity": 100, 1136 | "angle": 0, 1137 | "x": 338.56648738801533, 1138 | "y": 285.09150013599765, 1139 | "strokeColor": "#000000", 1140 | "backgroundColor": "#fa5252", 1141 | "width": 515.51953125, 1142 | "height": 24, 1143 | "seed": 761299414, 1144 | "groupIds": [ 1145 | "Agmx0PzmSvBRtRl69QbCW" 1146 | ], 1147 | "roundness": null, 1148 | "boundElements": [], 1149 | "updated": 1682459650654, 1150 | "link": null, 1151 | "locked": false, 1152 | "fontSize": 20, 1153 | "fontFamily": 1, 1154 | "text": "http://localhost.localstack.cloud:4566 ", 1155 | "textAlign": "left", 1156 | "verticalAlign": "top", 1157 | "containerId": null, 1158 | "originalText": "http://localhost.localstack.cloud:4566 ", 1159 | "lineHeight": 1.2, 1160 | "baseline": 17 1161 | }, 1162 | { 1163 | "type": "text", 1164 | "version": 695, 1165 | "versionNonce": 1206194826, 1166 | "isDeleted": false, 1167 | "id": "txYzob2tRmtnxw6TmAlnN", 1168 | "fillStyle": "solid", 1169 | "strokeWidth": 2, 1170 | "strokeStyle": "solid", 1171 | "roughness": 1, 1172 | "opacity": 100, 1173 | "angle": 0, 1174 | "x": 493.8296023172145, 1175 | "y": 324.34748158131015, 1176 | "strokeColor": "#000000", 1177 | "backgroundColor": "#fa5252", 1178 | "width": 194.89981079101562, 1179 | "height": 24, 1180 | "seed": 685627158, 1181 | "groupIds": [ 1182 | "Agmx0PzmSvBRtRl69QbCW" 1183 | ], 1184 | "roundness": null, 1185 | "boundElements": [], 1186 | "updated": 1682459650654, 1187 | "link": null, 1188 | "locked": false, 1189 | "fontSize": 20, 1190 | "fontFamily": 1, 1191 | "text": "Secrets: omdbApiKey", 1192 | "textAlign": "left", 1193 | "verticalAlign": "top", 1194 | "containerId": null, 1195 | "originalText": "Secrets: omdbApiKey", 1196 | "lineHeight": 1.2, 1197 | "baseline": 17 1198 | }, 1199 | { 1200 | "type": "arrow", 1201 | "version": 78, 1202 | "versionNonce": 1649048970, 1203 | "isDeleted": false, 1204 | "id": "O4810p3r-IFjKEoCLEU6O", 1205 | "fillStyle": "solid", 1206 | "strokeWidth": 1, 1207 | "strokeStyle": "solid", 1208 | "roughness": 1, 1209 | "opacity": 100, 1210 | "angle": 0, 1211 | "x": 48.86594796586485, 1212 | "y": 29.602364842014282, 1213 | "strokeColor": "#000000", 1214 | "backgroundColor": "#be4bdb", 1215 | "width": 112.68038940429688, 1216 | "height": 59.313079833984375, 1217 | "seed": 209618006, 1218 | "groupIds": [], 1219 | "roundness": { 1220 | "type": 2 1221 | }, 1222 | "boundElements": [], 1223 | "updated": 1682459655582, 1224 | "link": null, 1225 | "locked": false, 1226 | "startBinding": { 1227 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 1228 | "focus": 0.486848598280453, 1229 | "gap": 3.4297427552230886 1230 | }, 1231 | "endBinding": { 1232 | "elementId": "uzZEmVQIV4PY6hbv8r60K", 1233 | "focus": 0.6305264268957453, 1234 | "gap": 10.540535094854363 1235 | }, 1236 | "lastCommittedPoint": null, 1237 | "startArrowhead": "arrow", 1238 | "endArrowhead": "arrow", 1239 | "points": [ 1240 | [ 1241 | 0, 1242 | 0 1243 | ], 1244 | [ 1245 | 112.68038940429688, 1246 | -59.313079833984375 1247 | ] 1248 | ] 1249 | }, 1250 | { 1251 | "type": "arrow", 1252 | "version": 92, 1253 | "versionNonce": 466450454, 1254 | "isDeleted": false, 1255 | "id": "DZpByHGRVUSt0NLO63Wcb", 1256 | "fillStyle": "solid", 1257 | "strokeWidth": 1, 1258 | "strokeStyle": "solid", 1259 | "roughness": 1, 1260 | "opacity": 100, 1261 | "angle": 0, 1262 | "x": 54.36665795385943, 1263 | "y": 58.51484042795178, 1264 | "strokeColor": "#000000", 1265 | "backgroundColor": "#be4bdb", 1266 | "width": 111.6807861328125, 1267 | "height": 68.93716430664062, 1268 | "seed": 706185622, 1269 | "groupIds": [], 1270 | "roundness": { 1271 | "type": 2 1272 | }, 1273 | "boundElements": [], 1274 | "updated": 1682459655582, 1275 | "link": null, 1276 | "locked": false, 1277 | "startBinding": { 1278 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 1279 | "focus": -0.41046802636463825, 1280 | "gap": 8.930452743217671 1281 | }, 1282 | "endBinding": { 1283 | "elementId": "HWLjFHXE-5HDxDlkKzLeV", 1284 | "focus": -0.8514902891682656, 1285 | "gap": 7.434326171875 1286 | }, 1287 | "lastCommittedPoint": null, 1288 | "startArrowhead": "arrow", 1289 | "endArrowhead": "arrow", 1290 | "points": [ 1291 | [ 1292 | 0, 1293 | 0 1294 | ], 1295 | [ 1296 | 111.6807861328125, 1297 | 68.93716430664062 1298 | ] 1299 | ] 1300 | }, 1301 | { 1302 | "type": "arrow", 1303 | "version": 65, 1304 | "versionNonce": 1811299402, 1305 | "isDeleted": false, 1306 | "id": "H7fJvZ3SSKrOzTdLYAcrA", 1307 | "fillStyle": "solid", 1308 | "strokeWidth": 1, 1309 | "strokeStyle": "solid", 1310 | "roughness": 1, 1311 | "opacity": 100, 1312 | "angle": 0, 1313 | "x": 165.64461205542193, 1314 | "y": 287.9358914533424, 1315 | "strokeColor": "#000000", 1316 | "backgroundColor": "#be4bdb", 1317 | "width": 137.25479125976562, 1318 | "height": 192.4349365234375, 1319 | "seed": 1356719830, 1320 | "groupIds": [], 1321 | "roundness": { 1322 | "type": 2 1323 | }, 1324 | "boundElements": [], 1325 | "updated": 1682459655582, 1326 | "link": null, 1327 | "locked": false, 1328 | "startBinding": { 1329 | "elementId": "qpCMVexxUXjZVbfsYRYZc", 1330 | "focus": -0.8995478467192235, 1331 | "gap": 7.59283447265625 1332 | }, 1333 | "endBinding": { 1334 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 1335 | "focus": -0.31880380820241117, 1336 | "gap": 10.268405968192411 1337 | }, 1338 | "lastCommittedPoint": null, 1339 | "startArrowhead": null, 1340 | "endArrowhead": "arrow", 1341 | "points": [ 1342 | [ 1343 | 0, 1344 | 0 1345 | ], 1346 | [ 1347 | -137.25479125976562, 1348 | -192.4349365234375 1349 | ] 1350 | ] 1351 | }, 1352 | { 1353 | "type": "arrow", 1354 | "version": 167, 1355 | "versionNonce": 206760598, 1356 | "isDeleted": false, 1357 | "id": "klP8oLqpyYNCFYUF0yHAF", 1358 | "fillStyle": "solid", 1359 | "strokeWidth": 1, 1360 | "strokeStyle": "solid", 1361 | "roughness": 1, 1362 | "opacity": 100, 1363 | "angle": 0, 1364 | "x": 165.84834740698443, 1365 | "y": 147.5256816384663, 1366 | "strokeColor": "#000000", 1367 | "backgroundColor": "#be4bdb", 1368 | "width": 111.51290893554688, 1369 | "height": 102.44903564453125, 1370 | "seed": 560372758, 1371 | "groupIds": [], 1372 | "roundness": { 1373 | "type": 2 1374 | }, 1375 | "boundElements": [], 1376 | "updated": 1682459660630, 1377 | "link": null, 1378 | "locked": false, 1379 | "startBinding": { 1380 | "elementId": "HWLjFHXE-5HDxDlkKzLeV", 1381 | "focus": 0.7575509965491681, 1382 | "gap": 7.6334228515625 1383 | }, 1384 | "endBinding": { 1385 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 1386 | "focus": 0.6027458506451883, 1387 | "gap": 10.915409784914146 1388 | }, 1389 | "lastCommittedPoint": null, 1390 | "startArrowhead": null, 1391 | "endArrowhead": "arrow", 1392 | "points": [ 1393 | [ 1394 | 0, 1395 | 0 1396 | ], 1397 | [ 1398 | -111.51290893554688, 1399 | 102.44903564453125 1400 | ] 1401 | ] 1402 | } 1403 | ], 1404 | "appState": { 1405 | "gridSize": null, 1406 | "viewBackgroundColor": "#ffffff" 1407 | }, 1408 | "files": {} 1409 | } -------------------------------------------------------------------------------- /documentation/project-diagram.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-aws-localstack-opensearch-s3-secretsmanager/e7acae4dcdcc512dff3f666eae0909ae669d822c/documentation/project-diagram.jpeg -------------------------------------------------------------------------------- /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 | if [ -z "$1" ]; then 9 | echo "WARNING: OMDB_API_KEY must be informed as 1st parameter" 10 | exit 1 11 | fi 12 | 13 | AWS_REGION=eu-west-1 14 | OPENSEARCH_DOMAIN_NAME=my-domain 15 | OMDB_API_KEY=$1 16 | 17 | echo 18 | echo "Initializing LocalStack" 19 | echo "=======================" 20 | 21 | echo 22 | echo "Installing jq" 23 | echo "-------------" 24 | docker exec -t localstack apt-get -y install jq 25 | 26 | echo 27 | echo "Creating OpenSearch domain" 28 | echo "--------------------------" 29 | docker exec -t localstack aws --endpoint-url=http://localhost:4566 opensearch create-domain --domain-name $OPENSEARCH_DOMAIN_NAME 30 | 31 | echo 32 | echo "Waiting for OpenSearch domain creation to complete" 33 | echo "--------------------------------------------------" 34 | TIMEOUT=$((7 * 60)) # set timeout to 7 minutes 35 | WAIT_INTERVAL=1 36 | for ((i=0; i 2 | 4 | 4.0.0 5 | 6 | com.ivanfranchin 7 | springboot-aws-localstack-opensearch-s3-secretsmanager 8 | 1.0.0 9 | ../pom.xml 10 | 11 | movie-api 12 | movie-api 13 | Demo project for Spring Boot 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 2.30.35 29 | 3.3.0 30 | 2.19.0 31 | 2.8.3 32 | 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-starter-actuator 37 | 38 | 39 | org.springframework.boot 40 | spring-boot-starter-thymeleaf 41 | 42 | 43 | org.springframework.boot 44 | spring-boot-starter-validation 45 | 46 | 47 | org.springframework.boot 48 | spring-boot-starter-web 49 | 50 | 51 | io.awspring.cloud 52 | spring-cloud-aws-starter-s3 53 | 54 | 55 | io.awspring.cloud 56 | spring-cloud-aws-starter-secrets-manager 57 | 58 | 59 | 60 | 61 | org.opensearch.client 62 | opensearch-rest-high-level-client 63 | ${opensearch-client.version} 64 | 65 | 66 | 67 | 68 | org.springdoc 69 | springdoc-openapi-starter-webmvc-ui 70 | ${springdoc-openapi.version} 71 | 72 | 73 | 74 | org.projectlombok 75 | lombok 76 | true 77 | 78 | 79 | org.springframework.boot 80 | spring-boot-starter-test 81 | test 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | software.amazon.awssdk 90 | bom 91 | ${amazon-awssdk.version} 92 | pom 93 | import 94 | 95 | 96 | io.awspring.cloud 97 | spring-cloud-aws-dependencies 98 | ${spring-cloud-aws.version} 99 | pom 100 | import 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | org.apache.maven.plugins 109 | maven-compiler-plugin 110 | 111 | 112 | 113 | org.projectlombok 114 | lombok 115 | 116 | 117 | 118 | 119 | 120 | org.springframework.boot 121 | spring-boot-maven-plugin 122 | 123 | 124 | 125 | org.projectlombok 126 | lombok 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/MovieApiApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class MovieApiApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(MovieApiApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/aws/AwsProperties.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.aws; 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 | @NotBlank 18 | private String endpoint; 19 | 20 | @NotBlank 21 | private String region; 22 | 23 | @NotNull 24 | private OpenSearch opensearch; 25 | 26 | @NotNull 27 | private S3 s3; 28 | 29 | @Data 30 | @Valid 31 | public static class OpenSearch { 32 | 33 | @NotBlank 34 | private String domain; 35 | 36 | @NotBlank 37 | private String indexes; 38 | } 39 | 40 | @Data 41 | @Valid 42 | public static class S3 { 43 | 44 | @NotBlank 45 | private String bucketName; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/aws/OpenSearchConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.aws; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.apache.http.HttpHost; 5 | import org.opensearch.client.RestClient; 6 | import org.opensearch.client.RestHighLevelClient; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | 10 | @RequiredArgsConstructor 11 | @Configuration 12 | public class OpenSearchConfig { 13 | 14 | private final AwsProperties awsProperties; 15 | 16 | @Bean 17 | RestHighLevelClient restHighLevelClient() { 18 | return new RestHighLevelClient( 19 | RestClient.builder(HttpHost.create(awsProperties.getEndpoint())) 20 | .setPathPrefix(String.format("/opensearch/%s/%s", 21 | awsProperties.getRegion(), awsProperties.getOpensearch().getDomain()))); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/aws/OpenSearchService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.aws; 2 | 3 | import com.ivanfranchin.movieapi.movie.exception.OpenSearchServiceException; 4 | import lombok.RequiredArgsConstructor; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.opensearch.action.get.GetRequest; 7 | import org.opensearch.action.get.GetResponse; 8 | import org.opensearch.action.index.IndexRequest; 9 | import org.opensearch.action.index.IndexResponse; 10 | import org.opensearch.action.search.SearchRequest; 11 | import org.opensearch.action.search.SearchResponse; 12 | import org.opensearch.client.RequestOptions; 13 | import org.opensearch.client.RestHighLevelClient; 14 | import org.opensearch.common.xcontent.XContentType; 15 | import org.opensearch.index.query.QueryBuilders; 16 | import org.opensearch.search.SearchHits; 17 | import org.opensearch.search.builder.SearchSourceBuilder; 18 | import org.springframework.stereotype.Service; 19 | import org.springframework.util.StringUtils; 20 | 21 | import java.io.File; 22 | import java.net.MalformedURLException; 23 | import java.net.URI; 24 | import java.net.URISyntaxException; 25 | import java.net.URL; 26 | import java.util.Map; 27 | import java.util.Optional; 28 | 29 | @Slf4j 30 | @RequiredArgsConstructor 31 | @Service 32 | public class OpenSearchService { 33 | 34 | private final RestHighLevelClient restHighLevelClient; 35 | private final AwsProperties awsProperties; 36 | private final PosterService posterService; 37 | 38 | public Map getMovie(String imdb) { 39 | try { 40 | GetRequest getRequest = new GetRequest(awsProperties.getOpensearch().getIndexes(), imdb); 41 | GetResponse getResponse = restHighLevelClient.get(getRequest, RequestOptions.DEFAULT); 42 | return getResponse.getSource(); 43 | } catch (Exception e) { 44 | String errorMessage = String.format("An exception occurred while getting document with imdb '%s'. %s", imdb, e.getMessage()); 45 | throw new OpenSearchServiceException(errorMessage, e); 46 | } 47 | } 48 | 49 | public SearchHits searchMovies(String title) { 50 | try { 51 | SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); 52 | searchSourceBuilder.size(50).sort("createdAt"); 53 | if (StringUtils.hasText(title)) { 54 | searchSourceBuilder.query(QueryBuilders.matchQuery("title", title)); 55 | } 56 | SearchRequest searchRequest = new SearchRequest(awsProperties.getOpensearch().getIndexes()) 57 | .source(searchSourceBuilder); 58 | SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT); 59 | return searchResponse.getHits(); 60 | } catch (Exception e) { 61 | String errorMessage = String.format("An exception occurred while searching for title '%s'. %s", title, e.getMessage()); 62 | throw new OpenSearchServiceException(errorMessage, e); 63 | } 64 | } 65 | 66 | public Map saveMovie(Map movieMap) { 67 | try { 68 | handlePoster(movieMap); 69 | IndexRequest indexRequest = new IndexRequest(awsProperties.getOpensearch().getIndexes()) 70 | .source(movieMap, XContentType.JSON) 71 | .id(String.valueOf(movieMap.get("imdb"))); 72 | IndexResponse indexResponse = restHighLevelClient.index(indexRequest, RequestOptions.DEFAULT); 73 | log.info("Document for '{}' {} successfully in ES!", movieMap, indexResponse.getResult()); 74 | // We could have set back to the movieMap the indexResponse.getId() value 75 | // However, the imdb is already the id so, we keep the same movieMap 76 | return movieMap; 77 | } catch (Exception e) { 78 | String errorMessage = String.format("An exception occurred while indexing '%s'. %s", movieMap, e.getMessage()); 79 | throw new OpenSearchServiceException(errorMessage, e); 80 | } 81 | } 82 | 83 | private void handlePoster(Map movieMap) { 84 | String poster = posterService.getPosterNotAvailableUrl(); 85 | if (movieMap.containsKey("poster") && movieMap.get("poster") != null) { 86 | poster = String.valueOf(movieMap.get("poster")); 87 | } else if (movieMap.containsKey("posterUrl")) { 88 | Object posterUrlObj = movieMap.get("posterUrl"); 89 | if ((posterUrlObj instanceof URL || posterUrlObj instanceof String)) { 90 | URL posterUrl = posterUrlObj instanceof URL ? 91 | (URL) posterUrlObj : validateAndGetUrl((String) posterUrlObj); 92 | if (posterUrl != null) { 93 | String imdb = String.valueOf(movieMap.get("imdb")); 94 | Optional filePathOptional = posterService.downloadFile(posterUrl, imdb); 95 | poster = filePathOptional.isPresent() ? 96 | posterService.uploadFile(new File(filePathOptional.get())) : posterService.getPosterNotAvailableUrl(); 97 | movieMap.remove("posterUrl"); 98 | } 99 | } 100 | } 101 | movieMap.put("poster", poster); 102 | } 103 | 104 | private URL validateAndGetUrl(String url) { 105 | if (StringUtils.hasText(url)) { 106 | try { 107 | return new URI(url).toURL(); 108 | } catch (URISyntaxException e) { 109 | log.error("Invalid URL syntax: {}", e.getMessage()); 110 | } catch (MalformedURLException e) { 111 | log.error("Malformed URL: {}", e.getMessage()); 112 | } 113 | } 114 | return null; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/aws/PosterService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.aws; 2 | 3 | import com.ivanfranchin.movieapi.movie.exception.PosterUploaderException; 4 | import io.awspring.cloud.s3.S3Template; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.stereotype.Service; 8 | import org.springframework.util.ResourceUtils; 9 | import org.springframework.web.multipart.MultipartFile; 10 | 11 | import java.io.File; 12 | import java.io.FileInputStream; 13 | import java.io.FileNotFoundException; 14 | import java.io.FileOutputStream; 15 | import java.io.IOException; 16 | import java.io.InputStream; 17 | import java.net.URL; 18 | import java.nio.channels.Channels; 19 | import java.nio.channels.FileChannel; 20 | import java.nio.channels.ReadableByteChannel; 21 | import java.nio.file.Files; 22 | import java.nio.file.Paths; 23 | import java.util.Optional; 24 | 25 | @Slf4j 26 | @RequiredArgsConstructor 27 | @Service 28 | public class PosterService { 29 | 30 | private final S3Template s3Template; 31 | private final AwsProperties awsProperties; 32 | 33 | public String getPosterNotAvailableUrl() { 34 | return String.format("%s/%s/%s", 35 | awsProperties.getEndpoint(), awsProperties.getS3().getBucketName(), NOT_AVAILABLE_POSTER); 36 | } 37 | 38 | public Optional downloadFile(URL fileUrl, String fileName) { 39 | try { 40 | Files.createDirectories(Paths.get(TMP_FOLDER)); 41 | String filePath = String.format("%s/%s.jpg", TMP_FOLDER, fileName); 42 | try (ReadableByteChannel readableByteChannel = Channels.newChannel(fileUrl.openStream()); 43 | FileOutputStream fileOutputStream = new FileOutputStream(filePath); 44 | FileChannel fileChannel = fileOutputStream.getChannel()) { 45 | fileChannel.transferFrom(readableByteChannel, 0, Long.MAX_VALUE); 46 | return Optional.of(filePath); 47 | } 48 | } catch (IOException e) { 49 | log.error("Unable to download file from URL '{}'. Error message: {}", fileUrl, e.getMessage()); 50 | return Optional.empty(); 51 | } 52 | } 53 | 54 | public String uploadFile(MultipartFile file) { 55 | try { 56 | return uploadFile(file.getOriginalFilename(), file.getInputStream()); 57 | } catch (IOException e) { 58 | String message = String.format("Unable to upload MultipartFile %s", file.getOriginalFilename()); 59 | throw new PosterUploaderException(message, e); 60 | } 61 | } 62 | 63 | public String uploadFile(File file) { 64 | try { 65 | return uploadFile(file.getName(), new FileInputStream(file)); 66 | } catch (IOException e) { 67 | String message = String.format("Unable to upload File %s", file.getName()); 68 | throw new PosterUploaderException(message, e); 69 | } 70 | } 71 | 72 | private String uploadFile(String key, InputStream inputStream) { 73 | String bucketName = awsProperties.getS3().getBucketName(); 74 | s3Template.upload(bucketName, key, inputStream); 75 | String s3FileUrl = String.format("%s/%s/%s", awsProperties.getEndpoint(), bucketName, key); 76 | log.info("File '{}' uploaded successfully in S3! URL: {}", key, s3FileUrl); 77 | return s3FileUrl; 78 | } 79 | 80 | private static final String TMP_FOLDER = "tmp/posters"; 81 | public static final String NOT_AVAILABLE_POSTER = "not-available.jpg"; 82 | public static final File NOT_AVAILABLE_POSTER_FILE; 83 | 84 | static { 85 | try { 86 | NOT_AVAILABLE_POSTER_FILE = ResourceUtils.getFile("classpath:images/" + NOT_AVAILABLE_POSTER); 87 | } catch (FileNotFoundException e) { 88 | throw new RuntimeException(e); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/aws/S3Service.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.aws; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.stereotype.Service; 5 | import software.amazon.awssdk.services.s3.S3Client; 6 | import software.amazon.awssdk.services.s3.model.ListObjectsRequest; 7 | 8 | @RequiredArgsConstructor 9 | @Service 10 | public class S3Service { 11 | 12 | private final S3Client s3Client; 13 | private final AwsProperties awsProperties; 14 | 15 | public boolean isFilePresent(String fileName) { 16 | ListObjectsRequest listObjectsRequest = ListObjectsRequest.builder() 17 | .bucket(awsProperties.getS3().getBucketName()) 18 | .build(); 19 | 20 | return s3Client.listObjects(listObjectsRequest) 21 | .contents() 22 | .stream() 23 | .anyMatch(s3Object -> s3Object.key().equals(fileName)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/config/ErrorAttributesConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.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 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/config/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.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 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/MovieService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie; 2 | 3 | import com.ivanfranchin.movieapi.aws.OpenSearchService; 4 | import com.ivanfranchin.movieapi.movie.exception.MovieNotFoundException; 5 | import com.ivanfranchin.movieapi.movie.model.Movie; 6 | import com.ivanfranchin.movieapi.movie.rest.dto.SearchResponse; 7 | import lombok.RequiredArgsConstructor; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.stereotype.Service; 10 | 11 | import java.util.List; 12 | import java.util.Map; 13 | import java.util.Optional; 14 | 15 | @Slf4j 16 | @RequiredArgsConstructor 17 | @Service 18 | public class MovieService { 19 | 20 | private final OpenSearchService openSearchService; 21 | 22 | public Optional getMovie(String imdb) { 23 | try { 24 | return Optional.of(Movie.from(openSearchService.getMovie(imdb))); 25 | } catch (Exception e) { 26 | return Optional.empty(); 27 | } 28 | } 29 | 30 | public List getMovies() { 31 | return SearchResponse.toMovieList(searchMovies(null)); 32 | } 33 | 34 | public Movie validateAndGetMovie(String imdb) { 35 | return getMovie(imdb) 36 | .orElseThrow(() -> new MovieNotFoundException(String.format("Movie with imdb '%s' not found", imdb))); 37 | } 38 | 39 | public SearchResponse searchMovies(String title) { 40 | try { 41 | return SearchResponse.from(openSearchService.searchMovies(title)); 42 | } catch (Exception e) { 43 | return SearchResponse.createError(e.getMessage()); 44 | } 45 | } 46 | 47 | public Movie saveMovie(Movie movie) { 48 | Map movieMap = openSearchService.saveMovie(Movie.toMap(movie)); 49 | return Movie.from(movieMap); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/controller/MoviesUiController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.controller; 2 | 3 | import com.ivanfranchin.movieapi.aws.PosterService; 4 | import com.ivanfranchin.movieapi.movie.controller.dto.SearchRequest; 5 | import com.ivanfranchin.movieapi.omdb.OmdbApiClient; 6 | import com.ivanfranchin.movieapi.omdb.OmdbResponse; 7 | import com.ivanfranchin.movieapi.movie.model.Movie; 8 | import com.ivanfranchin.movieapi.omdb.OmdbApiProperties; 9 | import com.ivanfranchin.movieapi.movie.MovieService; 10 | import lombok.RequiredArgsConstructor; 11 | import lombok.extern.slf4j.Slf4j; 12 | import org.springframework.stereotype.Controller; 13 | import org.springframework.ui.Model; 14 | import org.springframework.util.StringUtils; 15 | import org.springframework.web.bind.annotation.GetMapping; 16 | import org.springframework.web.bind.annotation.ModelAttribute; 17 | import org.springframework.web.bind.annotation.PathVariable; 18 | import org.springframework.web.bind.annotation.PostMapping; 19 | import org.springframework.web.bind.annotation.PutMapping; 20 | import org.springframework.web.bind.annotation.RequestParam; 21 | import org.springframework.web.multipart.MultipartFile; 22 | import org.springframework.web.servlet.mvc.support.RedirectAttributes; 23 | 24 | import java.util.Optional; 25 | 26 | @Slf4j 27 | @RequiredArgsConstructor 28 | @Controller 29 | public class MoviesUiController { 30 | 31 | private final MovieService movieService; 32 | private final PosterService posterService; 33 | private final OmdbApiClient omdbApiClient; 34 | private final OmdbApiProperties omdbApiProperties; 35 | 36 | @GetMapping("/") 37 | public String getHome() { 38 | return "redirect:/movies"; 39 | } 40 | 41 | @GetMapping("/movies") 42 | public String getMovies(Model model) { 43 | model.addAttribute("searchRequest", new SearchRequest()); 44 | model.addAttribute("addOmdbResponse", new OmdbResponse()); 45 | model.addAttribute("movies", movieService.getMovies()); 46 | return "movies"; 47 | } 48 | 49 | @GetMapping("/movies/{imdb}") 50 | public String getMovie(@PathVariable String imdb, Model model) { 51 | Optional movieOptional = movieService.getMovie(imdb); 52 | if (movieOptional.isEmpty()) { 53 | return "redirect:/movies"; 54 | } 55 | model.addAttribute("movie", movieOptional.get()); 56 | return "movieDetail"; 57 | } 58 | 59 | @PostMapping("/movies/search") 60 | public String searchMovies(@ModelAttribute SearchRequest searchRequest, 61 | Model model, 62 | RedirectAttributes redirectAttributes) { 63 | if (!StringUtils.hasText(searchRequest.getText())) { 64 | return "redirect:/movies"; 65 | } 66 | OmdbResponse omdbResponse; 67 | try { 68 | omdbResponse = omdbApiClient.getMovieByTitle(omdbApiProperties.getApiKey(), searchRequest.getText()); 69 | } catch (Exception e) { 70 | redirectAttributes.addFlashAttribute("error", 71 | String.format("An error occurred while searching for title containing '%s' in OMDb API! Error message: %s", 72 | searchRequest.getText(), e.getMessage())); 73 | return "redirect:/movies"; 74 | } 75 | if ("False".equals(omdbResponse.getResponse())) { 76 | redirectAttributes.addFlashAttribute("error", 77 | String.format("No movies with title containing '%s' were found!", searchRequest.getText())); 78 | return "redirect:/movies"; 79 | } 80 | model.addAttribute("omdbResponse", omdbResponse); 81 | model.addAttribute("addOmdbResponse", omdbResponse); 82 | return "movies"; 83 | } 84 | 85 | @PutMapping("/movies") 86 | public String putMovie(@RequestParam MultipartFile posterFile, 87 | @ModelAttribute Movie movie, 88 | RedirectAttributes redirectAttributes) { 89 | try { 90 | if (!posterFile.isEmpty()) { 91 | movie.setPoster(posterService.uploadFile(posterFile)); 92 | } 93 | movieService.saveMovie(movie); 94 | redirectAttributes.addFlashAttribute("message", 95 | String.format("Movie '%s' updated successfully! Refresh the page in case it's not showing the updated version.", movie.getTitle())); 96 | } catch (Exception e) { 97 | redirectAttributes.addFlashAttribute("error", 98 | String.format("An error occurred while updating movie '%s'!", movie.getTitle())); 99 | } 100 | return "redirect:/movies"; 101 | } 102 | 103 | @PostMapping("/movies") 104 | public String postMovie(@ModelAttribute OmdbResponse addOmdbResponse, 105 | @RequestParam String action, 106 | RedirectAttributes redirectAttributes) { 107 | if ("cancel".equals(action)) { 108 | return "redirect:/movies"; 109 | } 110 | Movie movie = Movie.from(addOmdbResponse); 111 | try { 112 | movieService.saveMovie(movie); 113 | redirectAttributes.addFlashAttribute("message", 114 | String.format("Movie '%s' added successfully! Refresh the page in case it's not showing.", addOmdbResponse.getTitle())); 115 | } catch (Exception e) { 116 | redirectAttributes.addFlashAttribute("error", 117 | String.format("An error occurred while adding movie '%s'!", addOmdbResponse.getTitle())); 118 | } 119 | return "redirect:/movies"; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/controller/dto/SearchRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.controller.dto; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class SearchRequest { 7 | 8 | private String text; 9 | } 10 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/exception/MovieNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.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 MovieNotFoundException extends RuntimeException { 8 | 9 | public MovieNotFoundException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/exception/OpenSearchServiceException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.exception; 2 | 3 | public class OpenSearchServiceException extends RuntimeException { 4 | 5 | public OpenSearchServiceException(String message, Throwable cause) { 6 | super(message, cause); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/exception/PosterUploaderException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.exception; 2 | 3 | public class PosterUploaderException extends RuntimeException { 4 | 5 | public PosterUploaderException(String message, Throwable cause) { 6 | super(message, cause); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/model/Movie.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.model; 2 | 3 | import com.ivanfranchin.movieapi.omdb.OmdbResponse; 4 | import com.ivanfranchin.movieapi.movie.rest.dto.AddMovieRequest; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | 8 | import java.time.Clock; 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | 12 | @Data 13 | @AllArgsConstructor 14 | public class Movie { 15 | 16 | private String imdb; 17 | private String title; 18 | private String poster; 19 | private String posterUrl; 20 | private String year; 21 | private String released; 22 | private String imdbRating; 23 | private String genre; 24 | private String runtime; 25 | private String director; 26 | private String writer; 27 | private String actors; 28 | private String plot; 29 | private String language; 30 | private String country; 31 | private String awards; 32 | private Long createdAt; 33 | 34 | public static Movie from(AddMovieRequest addMovieRequest) { 35 | return new Movie( 36 | addMovieRequest.imdb(), 37 | addMovieRequest.title(), 38 | null, 39 | addMovieRequest.posterUrl().toString(), 40 | addMovieRequest.year(), 41 | addMovieRequest.released(), 42 | addMovieRequest.imdbRating(), 43 | addMovieRequest.genre(), 44 | addMovieRequest.runtime(), 45 | addMovieRequest.director(), 46 | addMovieRequest.writer(), 47 | addMovieRequest.actors(), 48 | addMovieRequest.plot(), 49 | addMovieRequest.language(), 50 | addMovieRequest.country(), 51 | addMovieRequest.awards(), 52 | Clock.systemDefaultZone().millis() 53 | ); 54 | } 55 | 56 | public static Movie from(OmdbResponse omdbResponse) { 57 | return new Movie( 58 | omdbResponse.getImdb(), 59 | omdbResponse.getTitle(), 60 | null, 61 | omdbResponse.getPosterUrl(), 62 | omdbResponse.getYear(), 63 | omdbResponse.getReleased(), 64 | omdbResponse.getImdbRating(), 65 | omdbResponse.getGenre(), 66 | omdbResponse.getRuntime(), 67 | omdbResponse.getDirector(), 68 | omdbResponse.getWriter(), 69 | omdbResponse.getActors(), 70 | omdbResponse.getPlot(), 71 | omdbResponse.getLanguage(), 72 | omdbResponse.getCountry(), 73 | omdbResponse.getAwards(), 74 | Clock.systemDefaultZone().millis() 75 | ); 76 | } 77 | 78 | public static Movie from(Map movieMap) { 79 | return new Movie( 80 | movieMap.get("imdb") != null ? String.valueOf(movieMap.get("imdb")) : null, 81 | movieMap.get("title") != null ? String.valueOf(movieMap.get("title")) : null, 82 | movieMap.get("poster") != null ? String.valueOf(movieMap.get("poster")) : null, 83 | movieMap.get("posterUrl") != null ? String.valueOf(movieMap.get("posterUrl")) : null, 84 | movieMap.get("year") != null ? String.valueOf(movieMap.get("year")) : null, 85 | movieMap.get("released") != null ? String.valueOf(movieMap.get("released")) : null, 86 | movieMap.get("imdbRating") != null ? String.valueOf(movieMap.get("imdbRating")) : null, 87 | movieMap.get("genre") != null ? String.valueOf(movieMap.get("genre")) : null, 88 | movieMap.get("runtime") != null ? String.valueOf(movieMap.get("runtime")) : null, 89 | movieMap.get("director") != null ? String.valueOf(movieMap.get("director")) : null, 90 | movieMap.get("writer") != null ? String.valueOf(movieMap.get("writer")) : null, 91 | movieMap.get("actors") != null ? String.valueOf(movieMap.get("actors")) : null, 92 | movieMap.get("plot") != null ? String.valueOf(movieMap.get("plot")) : null, 93 | movieMap.get("language") != null ? String.valueOf(movieMap.get("language")) : null, 94 | movieMap.get("country") != null ? String.valueOf(movieMap.get("country")) : null, 95 | movieMap.get("awards") != null ? String.valueOf(movieMap.get("awards")) : null, 96 | movieMap.get("createdAt") != null ? Long.valueOf(String.valueOf(movieMap.get("createdAt"))) : null 97 | ); 98 | } 99 | 100 | public static Map toMap(Movie movie) { 101 | Map movieMap = new HashMap<>(); 102 | movieMap.put("imdb", movie.getImdb()); 103 | movieMap.put("title", movie.getTitle()); 104 | movieMap.put("poster", movie.getPoster()); 105 | movieMap.put("posterUrl", movie.getPosterUrl()); 106 | movieMap.put("year", movie.getYear()); 107 | movieMap.put("released", movie.getReleased()); 108 | movieMap.put("imdbRating", movie.getImdbRating()); 109 | movieMap.put("genre", movie.getGenre()); 110 | movieMap.put("runtime", movie.getRuntime()); 111 | movieMap.put("director", movie.getDirector()); 112 | movieMap.put("writer", movie.getWriter()); 113 | movieMap.put("actors", movie.getActors()); 114 | movieMap.put("plot", movie.getPlot()); 115 | movieMap.put("language", movie.getLanguage()); 116 | movieMap.put("country", movie.getCountry()); 117 | movieMap.put("awards", movie.getAwards()); 118 | movieMap.put("createdAt", movie.getCreatedAt()); 119 | return movieMap; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/rest/MovieController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.rest; 2 | 3 | import com.ivanfranchin.movieapi.aws.PosterService; 4 | import com.ivanfranchin.movieapi.movie.model.Movie; 5 | import com.ivanfranchin.movieapi.movie.MovieService; 6 | import com.ivanfranchin.movieapi.movie.rest.dto.AddMovieRequest; 7 | import com.ivanfranchin.movieapi.movie.rest.dto.MovieResponse; 8 | import com.ivanfranchin.movieapi.movie.rest.dto.SearchResponse; 9 | import jakarta.validation.Valid; 10 | import lombok.RequiredArgsConstructor; 11 | import lombok.extern.slf4j.Slf4j; 12 | import org.springframework.http.HttpStatus; 13 | import org.springframework.http.MediaType; 14 | import org.springframework.web.bind.annotation.GetMapping; 15 | import org.springframework.web.bind.annotation.PathVariable; 16 | import org.springframework.web.bind.annotation.PostMapping; 17 | import org.springframework.web.bind.annotation.RequestBody; 18 | import org.springframework.web.bind.annotation.RequestMapping; 19 | import org.springframework.web.bind.annotation.RequestParam; 20 | import org.springframework.web.bind.annotation.ResponseStatus; 21 | import org.springframework.web.bind.annotation.RestController; 22 | import org.springframework.web.multipart.MultipartFile; 23 | 24 | @Slf4j 25 | @RequiredArgsConstructor 26 | @RestController 27 | @RequestMapping("/api/movies") 28 | public class MovieController { 29 | 30 | private final MovieService movieService; 31 | private final PosterService posterService; 32 | 33 | @GetMapping 34 | public SearchResponse searchMovie(@RequestParam(required = false) String title) { 35 | log.info("Search movie with title {}", title); 36 | return movieService.searchMovies(title); 37 | } 38 | 39 | @GetMapping("/{imdb}") 40 | public MovieResponse getMovie(@PathVariable String imdb) { 41 | log.info("Search movie with imdb {}", imdb); 42 | Movie movie = movieService.validateAndGetMovie(imdb); 43 | return MovieResponse.from(movie); 44 | } 45 | 46 | @ResponseStatus(HttpStatus.CREATED) 47 | @PostMapping 48 | public MovieResponse addMovie(@Valid @RequestBody AddMovieRequest addMovieRequest) { 49 | log.info("Add movie {}", addMovieRequest); 50 | Movie movie = movieService.saveMovie(Movie.from(addMovieRequest)); 51 | return MovieResponse.from(movie); 52 | } 53 | 54 | @PostMapping(value = "/{imdb}/uploadPoster", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) 55 | public String uploadPoster(@PathVariable String imdb, @RequestParam("poster") MultipartFile poster) { 56 | log.info("Upload poster with imdb {}", imdb); 57 | Movie movie = movieService.validateAndGetMovie(imdb); 58 | String uploadedFile = posterService.uploadFile(poster); 59 | movie.setPoster(uploadedFile); 60 | movieService.saveMovie(movie); 61 | return uploadedFile; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/rest/dto/AddMovieRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.rest.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | import java.net.URL; 7 | 8 | public record AddMovieRequest( 9 | @Schema(example = "tt0106489") @NotBlank String imdb, 10 | @Schema(example = "A Bronx Tale") @NotBlank String title, 11 | @Schema(example = "https://m.media-amazon.com/images/M/MV5BMTczOTczNjE3Ml5BMl5BanBnXkFtZTgwODEzMzg5MTI@._V1_SX300.jpg") URL posterUrl, 12 | @Schema(example = "1993") String year, @Schema(example = "01 Oct 1993") String released, 13 | @Schema(example = "7.8") String imdbRating, 14 | @Schema(example = "Crime, Drama") String genre, 15 | @Schema(example = "121 min") String runtime, 16 | @Schema(example = "Robert De Niro") String director, 17 | @Schema(example = "Chazz Palminteri") String writer, 18 | @Schema(example = "Robert De Niro, Chazz Palminteri, Lillo Brancato") String actors, 19 | @Schema(example = "A father becomes worried when a local gangster befriends his son in the Bronx in the 1960s.") String plot, 20 | @Schema(example = "English, Italian") String language, 21 | @Schema(example = "United States") String country, 22 | @Schema(example = "1 win & 3 nominations") String awards) { 23 | } 24 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/rest/dto/MovieResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.rest.dto; 2 | 3 | import com.ivanfranchin.movieapi.movie.model.Movie; 4 | 5 | public record MovieResponse( 6 | String imdb, String title, String poster, String year, String released, String imdbRating, 7 | String genre, String runtime, String director, String writer, String actors, String plot, 8 | String language, String country, String awards, Long createdAt) { 9 | 10 | public static MovieResponse from(Movie movie) { 11 | return new MovieResponse( 12 | movie.getImdb(), 13 | movie.getTitle(), 14 | movie.getPoster(), 15 | movie.getYear(), 16 | movie.getReleased(), 17 | movie.getImdbRating(), 18 | movie.getGenre(), 19 | movie.getRuntime(), 20 | movie.getDirector(), 21 | movie.getWriter(), 22 | movie.getActors(), 23 | movie.getPlot(), 24 | movie.getLanguage(), 25 | movie.getCountry(), 26 | movie.getAwards(), 27 | movie.getCreatedAt() 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/movie/rest/dto/SearchResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.movie.rest.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonInclude.Include; 5 | import com.ivanfranchin.movieapi.movie.model.Movie; 6 | import org.opensearch.search.SearchHit; 7 | import org.opensearch.search.SearchHits; 8 | 9 | import java.util.ArrayList; 10 | import java.util.Collections; 11 | import java.util.List; 12 | import java.util.Map; 13 | import java.util.stream.Collectors; 14 | 15 | @JsonInclude(Include.NON_NULL) 16 | public record SearchResponse(List hits, Error error) { 17 | 18 | public SearchResponse(List hits) { 19 | this(hits, null); 20 | } 21 | 22 | public SearchResponse(Error error) { 23 | this(null, error); 24 | } 25 | 26 | public record Hit(String index, String id, Float score, Map source) { 27 | } 28 | 29 | public record Error(String message) { 30 | } 31 | 32 | public static SearchResponse from(SearchHits searchHits) { 33 | List hits = new ArrayList<>(); 34 | for (SearchHit searchHit : searchHits.getHits()) { 35 | hits.add(new SearchResponse.Hit( 36 | searchHit.getIndex(), 37 | searchHit.getId(), 38 | searchHit.getScore(), 39 | searchHit.getSourceAsMap() 40 | )); 41 | } 42 | return new SearchResponse(hits); 43 | } 44 | 45 | public static List toMovieList(SearchResponse searchResponse) { 46 | if (searchResponse.hits() == null) { 47 | return Collections.emptyList(); 48 | } 49 | return searchResponse.hits() 50 | .stream() 51 | .map(SearchResponse.Hit::source) 52 | .map(Movie::from) 53 | .collect(Collectors.toList()); 54 | } 55 | 56 | public static SearchResponse createError(String errorMessage) { 57 | return new SearchResponse(new SearchResponse.Error(errorMessage)); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/omdb/OmdbApiClient.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.omdb; 2 | 3 | import org.springframework.web.bind.annotation.RequestParam; 4 | import org.springframework.web.service.annotation.GetExchange; 5 | import org.springframework.web.service.annotation.HttpExchange; 6 | 7 | @HttpExchange 8 | public interface OmdbApiClient { 9 | 10 | @GetExchange 11 | OmdbResponse getMovieByTitle(@RequestParam String apiKey, @RequestParam(name = "t") String title); 12 | } 13 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/omdb/OmdbApiClientConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.omdb; 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 OmdbApiClientConfig { 12 | 13 | @Value("${omdbapi.url}") 14 | private String omdbApiUrl; 15 | 16 | @Bean 17 | OmdbApiClient omdbApiClient(RestClient.Builder builder) { 18 | RestClient restClient = builder.baseUrl(omdbApiUrl).build(); 19 | RestClientAdapter adapter = RestClientAdapter.create(restClient); 20 | HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); 21 | return factory.createClient(OmdbApiClient.class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/omdb/OmdbApiProperties.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.omdb; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | import lombok.Data; 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.validation.annotation.Validated; 8 | 9 | @Data 10 | @Validated 11 | @Configuration 12 | @ConfigurationProperties(prefix = "omdbapi") 13 | public class OmdbApiProperties { 14 | 15 | @NotBlank 16 | private String url; 17 | 18 | @NotBlank 19 | private String apiKey; 20 | } 21 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/omdb/OmdbResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.omdb; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Data 8 | @NoArgsConstructor 9 | public class OmdbResponse { 10 | 11 | @JsonProperty("imdbID") 12 | private String imdb; 13 | 14 | @JsonProperty("Title") 15 | private String title; 16 | 17 | @JsonProperty("Poster") 18 | private String posterUrl; 19 | 20 | @JsonProperty("Year") 21 | private String year; 22 | 23 | @JsonProperty("Released") 24 | private String released; 25 | 26 | private String imdbRating; 27 | 28 | @JsonProperty("Genre") 29 | private String genre; 30 | 31 | @JsonProperty("Runtime") 32 | private String runtime; 33 | 34 | @JsonProperty("Director") 35 | private String director; 36 | 37 | @JsonProperty("Writer") 38 | private String writer; 39 | 40 | @JsonProperty("Actors") 41 | private String actors; 42 | 43 | @JsonProperty("Plot") 44 | private String plot; 45 | 46 | @JsonProperty("Language") 47 | private String language; 48 | 49 | @JsonProperty("Country") 50 | private String country; 51 | 52 | @JsonProperty("Awards") 53 | private String awards; 54 | 55 | @JsonProperty("Response") 56 | private String response; 57 | 58 | public OmdbResponse(String response) { 59 | this.response = response; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /movie-api/src/main/java/com/ivanfranchin/movieapi/runner/S3InitialUploadRunner.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi.runner; 2 | 3 | import com.ivanfranchin.movieapi.aws.PosterService; 4 | import com.ivanfranchin.movieapi.aws.S3Service; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.boot.CommandLineRunner; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.util.ResourceUtils; 9 | 10 | import java.io.FileNotFoundException; 11 | import java.util.List; 12 | 13 | @RequiredArgsConstructor 14 | @Component 15 | public class S3InitialUploadRunner implements CommandLineRunner { 16 | 17 | private final S3Service s3Service; 18 | private final PosterService posterService; 19 | 20 | @Override 21 | public void run(String... args) throws FileNotFoundException { 22 | for (String fileName : filesNames) { 23 | if (!s3Service.isFilePresent(fileName)) { 24 | posterService.uploadFile(ResourceUtils.getFile( 25 | String.format("classpath:%s/%s", IMAGES_FOLDER, fileName))); 26 | } 27 | } 28 | } 29 | 30 | private static final String IMAGES_FOLDER = "images"; 31 | private static final List filesNames = List.of(com.ivanfranchin.movieapi.aws.PosterService.NOT_AVAILABLE_POSTER); 32 | } 33 | -------------------------------------------------------------------------------- /movie-api/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | server.port=9080 2 | 3 | aws.endpoint=http://localhost.localstack.cloud:4566 4 | aws.region=${AWS_REGION} 5 | aws.opensearch.domain=my-domain 6 | aws.opensearch.indexes=movies 7 | aws.s3.bucketName=com.ivanfranchin.movieapi.posters 8 | 9 | spring.application.name=movie-api 10 | 11 | # It's needed for Thymeleaf to use PUT and DELETE 12 | spring.mvc.hiddenmethod.filter.enabled=true 13 | 14 | spring.cloud.aws.s3.endpoint=${aws.endpoint} 15 | spring.cloud.aws.s3.path-style-access-enabled=true 16 | spring.cloud.aws.secretsmanager.endpoint=${aws.endpoint} 17 | spring.config.import=aws-secretsmanager:/secrets/omdbApi 18 | 19 | omdbapi.url=http://www.omdbapi.com 20 | omdbapi.apiKey=${apiKey} 21 | 22 | management.endpoints.web.exposure.include=beans,env,health,info,metrics,mappings 23 | management.endpoint.health.show-details=always 24 | 25 | springdoc.show-actuator=true 26 | springdoc.swagger-ui.groups-order=DESC 27 | springdoc.swagger-ui.disable-swagger-default-url=true 28 | -------------------------------------------------------------------------------- /movie-api/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ _ 2 | _ __ ___ _____ _(_) ___ __ _ _ __ (_) 3 | | '_ ` _ \ / _ \ \ / / |/ _ \_____ / _` | '_ \| | 4 | | | | | | | (_) \ V /| | __/_____| (_| | |_) | | 5 | |_| |_| |_|\___/ \_/ |_|\___| \__,_| .__/|_| 6 | |_| 7 | :: Spring Boot :: ${spring-boot.formatted-version} 8 | -------------------------------------------------------------------------------- /movie-api/src/main/resources/images/not-available.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-aws-localstack-opensearch-s3-secretsmanager/e7acae4dcdcc512dff3f666eae0909ae669d822c/movie-api/src/main/resources/images/not-available.jpg -------------------------------------------------------------------------------- /movie-api/src/main/resources/static/app.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $('.message').on('click', function() { 3 | $(this).closest('.message').transition('fade') 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /movie-api/src/main/resources/templates/movieDetail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Movies-Api 6 | 7 | 8 | 9 | 10 | 11 |
12 | 15 | 16 |
17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 |
34 |
35 | 36 | 37 |
38 |
39 |
40 | 41 | 42 |
43 |
44 |
45 | 46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 | 54 | 55 |
56 |
57 |
58 | 59 | 60 |
61 |
62 |
63 | 64 | 65 |
66 |
67 | 68 | 69 |
70 |
71 |
72 | 73 | 74 |
75 |
76 | 77 | 78 |
79 |
80 |
81 | 82 | 83 |
84 |
85 | 86 | 87 |
88 |
89 | 90 | 91 |
92 |
93 | 94 |
95 |
96 |
97 |
98 |
99 |
100 | 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /movie-api/src/main/resources/templates/movies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Movies-API 6 | 7 | 8 | 9 | 10 | 11 |
12 | 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | 31 |
32 | 42 | 43 |
44 |
45 | 46 |
47 |
48 |
49 |
50 |
Imdb
51 | 52 |
53 |
54 |
Title
55 | 56 |
57 |
58 |
Year
59 | 60 |
61 |
62 |
Released
63 | 64 |
65 |
66 |
Imdb Rating
67 | 68 |
69 |
70 |
Genre
71 | 72 |
73 |
74 |
Runtime
75 | 76 |
77 |
78 |
Director
79 | 80 |
81 |
82 |
Writer
83 | 84 |
85 |
86 |
Actors
87 | 88 |
89 |
90 |
Plot
91 | 92 |
93 |
94 |
Language
95 | 96 |
97 |
98 |
Country
99 | 100 |
101 |
102 |
Awards
103 | 104 |
105 |
106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 |
122 | 123 |
124 | 125 |
126 |
127 |
128 |
129 |
130 |
131 | 132 |
133 | 134 | 135 | 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /movie-api/src/test/java/com/ivanfranchin/movieapi/MovieApiApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieapi; 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 MovieApiApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /movie-ui/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.ivanfranchin 7 | springboot-aws-localstack-opensearch-s3-secretsmanager 8 | 1.0.0 9 | ../pom.xml 10 | 11 | movie-ui 12 | movie-ui 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 | 46 | org.projectlombok 47 | lombok 48 | true 49 | 50 | 51 | org.springframework.boot 52 | spring-boot-starter-test 53 | test 54 | 55 | 56 | 57 | 58 | 59 | 60 | org.apache.maven.plugins 61 | maven-compiler-plugin 62 | 63 | 64 | 65 | org.projectlombok 66 | lombok 67 | 68 | 69 | 70 | 71 | 72 | org.springframework.boot 73 | spring-boot-maven-plugin 74 | 75 | 76 | 77 | org.projectlombok 78 | lombok 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /movie-ui/src/main/java/com/ivanfranchin/movieui/MovieUiApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieui; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class MovieUiApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(MovieUiApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /movie-ui/src/main/java/com/ivanfranchin/movieui/client/MovieApiClient.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieui.client; 2 | 3 | import com.ivanfranchin.movieui.movie.dto.SearchResponse; 4 | import com.ivanfranchin.movieui.movie.model.Movie; 5 | import org.springframework.web.bind.annotation.PathVariable; 6 | import org.springframework.web.bind.annotation.RequestParam; 7 | import org.springframework.web.service.annotation.GetExchange; 8 | import org.springframework.web.service.annotation.HttpExchange; 9 | 10 | @HttpExchange("/api/movies") 11 | public interface MovieApiClient { 12 | 13 | @GetExchange("/{imdb}") 14 | Movie getMovie(@PathVariable String imdb); 15 | 16 | @GetExchange 17 | SearchResponse searchMovies(@RequestParam(required = false) String title); 18 | } 19 | -------------------------------------------------------------------------------- /movie-ui/src/main/java/com/ivanfranchin/movieui/client/MovieApiClientConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieui.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 MovieApiClientConfig { 12 | 13 | @Value("${movie-api.url}") 14 | private String movieApiUrl; 15 | 16 | @Bean 17 | MovieApiClient movieApiClient(RestClient.Builder builder) { 18 | RestClient restClient = builder.baseUrl(movieApiUrl).build(); 19 | RestClientAdapter adapter = RestClientAdapter.create(restClient); 20 | HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); 21 | return factory.createClient(MovieApiClient.class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /movie-ui/src/main/java/com/ivanfranchin/movieui/movie/MovieService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieui.movie; 2 | 3 | import com.ivanfranchin.movieui.client.MovieApiClient; 4 | import com.ivanfranchin.movieui.movie.dto.SearchResponse; 5 | import com.ivanfranchin.movieui.movie.model.Movie; 6 | import lombok.RequiredArgsConstructor; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.util.List; 11 | import java.util.Optional; 12 | 13 | @Slf4j 14 | @RequiredArgsConstructor 15 | @Service 16 | public class MovieService { 17 | 18 | private final MovieApiClient movieApiClient; 19 | 20 | public Optional getMovie(String imdb) { 21 | try { 22 | Movie movie = movieApiClient.getMovie(imdb); 23 | return Optional.of(movie); 24 | } catch (Exception e) { 25 | log.error("An exception happened while getting movie imdb '{}' from movie-api. Error message: {}", imdb, e.getMessage()); 26 | return Optional.empty(); 27 | } 28 | } 29 | 30 | public List getMovies() { 31 | return searchMovies(null); 32 | } 33 | 34 | public List searchMovies(String title) { 35 | try { 36 | return SearchResponse.toMovieList(movieApiClient.searchMovies(title == null ? "" : title)); 37 | } catch (Exception e) { 38 | log.error("An exception happened while searching for '{}' in movie-api. Error message: {}", title, e.getMessage()); 39 | return List.of(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /movie-ui/src/main/java/com/ivanfranchin/movieui/movie/MoviesController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieui.movie; 2 | 3 | import com.ivanfranchin.movieui.movie.dto.SearchRequest; 4 | import com.ivanfranchin.movieui.movie.model.Movie; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.stereotype.Controller; 8 | import org.springframework.ui.Model; 9 | import org.springframework.util.StringUtils; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.ModelAttribute; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.servlet.mvc.support.RedirectAttributes; 15 | 16 | import java.util.List; 17 | import java.util.Optional; 18 | 19 | @Slf4j 20 | @RequiredArgsConstructor 21 | @Controller 22 | public class MoviesController { 23 | 24 | private final MovieService movieService; 25 | 26 | @GetMapping("/") 27 | public String getHome() { 28 | return "redirect:/movies"; 29 | } 30 | 31 | @GetMapping("/movies") 32 | public String getMovies(Model model) { 33 | model.addAttribute("searchRequest", new SearchRequest()); 34 | model.addAttribute("movies", movieService.getMovies()); 35 | return "movies"; 36 | } 37 | 38 | @GetMapping("/movies/{imdb}") 39 | public String getMovie(@PathVariable String imdb, Model model) { 40 | Optional movieOptional = movieService.getMovie(imdb); 41 | if (movieOptional.isEmpty()) { 42 | return "redirect:/movies"; 43 | } 44 | model.addAttribute("movie", movieOptional.get()); 45 | return "movieDetail"; 46 | } 47 | 48 | @PostMapping("/movies/search") 49 | public String searchMovies(@ModelAttribute SearchRequest searchRequest, 50 | Model model, 51 | RedirectAttributes redirectAttributes) { 52 | if (!StringUtils.hasText(searchRequest.getText())) { 53 | return "redirect:/movies"; 54 | } 55 | List movies = movieService.searchMovies(searchRequest.getText()); 56 | if (movies.isEmpty()) { 57 | redirectAttributes.addFlashAttribute("message", 58 | String.format("No movies with title containing '%s' were found!", searchRequest.getText())); 59 | return "redirect:/movies"; 60 | } 61 | model.addAttribute("movies", movies); 62 | return "movies"; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /movie-ui/src/main/java/com/ivanfranchin/movieui/movie/dto/SearchRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieui.movie.dto; 2 | 3 | import lombok.Data; 4 | 5 | @Data 6 | public class SearchRequest { 7 | 8 | private String text; 9 | } 10 | -------------------------------------------------------------------------------- /movie-ui/src/main/java/com/ivanfranchin/movieui/movie/dto/SearchResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieui.movie.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonInclude.Include; 5 | import com.ivanfranchin.movieui.movie.model.Movie; 6 | 7 | import java.util.Collections; 8 | import java.util.List; 9 | import java.util.Map; 10 | import java.util.stream.Collectors; 11 | 12 | @JsonInclude(Include.NON_NULL) 13 | public record SearchResponse(List hits, Error error) { 14 | 15 | public SearchResponse() { 16 | this(null, null); 17 | } 18 | 19 | public record Hit(String index, String id, Float score, Map source) { 20 | } 21 | 22 | public record Error(String message) { 23 | } 24 | 25 | public static List toMovieList(SearchResponse searchResponse) { 26 | if (searchResponse.hits() == null) { 27 | return Collections.emptyList(); 28 | } 29 | return searchResponse.hits() 30 | .stream() 31 | .map(SearchResponse.Hit::source) 32 | .map(Movie::from) 33 | .collect(Collectors.toList()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /movie-ui/src/main/java/com/ivanfranchin/movieui/movie/model/Movie.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieui.movie.model; 2 | 3 | import java.util.Map; 4 | 5 | public record Movie( 6 | String imdb, String title, String poster, String year, String released, String imdbRating, 7 | String genre, String runtime, String director, String writer, String actors, String plot, 8 | String language, String country, String awards, Long createdAt) { 9 | 10 | public static Movie from(Map movieMap) { 11 | return new Movie( 12 | movieMap.get("imdb") != null ? String.valueOf(movieMap.get("imdb")) : null, 13 | movieMap.get("title") != null ? String.valueOf(movieMap.get("title")) : null, 14 | movieMap.get("poster") != null ? String.valueOf(movieMap.get("poster")) : null, 15 | movieMap.get("year") != null ? String.valueOf(movieMap.get("year")) : null, 16 | movieMap.get("released") != null ? String.valueOf(movieMap.get("released")) : null, 17 | movieMap.get("imdbRating") != null ? String.valueOf(movieMap.get("imdbRating")) : null, 18 | movieMap.get("genre") != null ? String.valueOf(movieMap.get("genre")) : null, 19 | movieMap.get("runtime") != null ? String.valueOf(movieMap.get("runtime")) : null, 20 | movieMap.get("director") != null ? String.valueOf(movieMap.get("director")) : null, 21 | movieMap.get("writer") != null ? String.valueOf(movieMap.get("writer")) : null, 22 | movieMap.get("actors") != null ? String.valueOf(movieMap.get("actors")) : null, 23 | movieMap.get("plot") != null ? String.valueOf(movieMap.get("plot")) : null, 24 | movieMap.get("language") != null ? String.valueOf(movieMap.get("language")) : null, 25 | movieMap.get("country") != null ? String.valueOf(movieMap.get("country")) : null, 26 | movieMap.get("awards") != null ? String.valueOf(movieMap.get("awards")) : null, 27 | movieMap.get("createdAt") != null ? Long.valueOf(String.valueOf(movieMap.get("createdAt"))) : null 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /movie-ui/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | server.port=9081 2 | 3 | spring.application.name=movie-ui 4 | 5 | movie-api.url=http://localhost:9080 6 | 7 | management.endpoints.web.exposure.include=beans,env,health,info,metrics,mappings 8 | management.endpoint.health.show-details=always 9 | -------------------------------------------------------------------------------- /movie-ui/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ _ 2 | _ __ ___ _____ _(_) ___ _ _(_) 3 | | '_ ` _ \ / _ \ \ / / |/ _ \_____| | | | | 4 | | | | | | | (_) \ V /| | __/_____| |_| | | 5 | |_| |_| |_|\___/ \_/ |_|\___| \__,_|_| 6 | :: Spring Boot :: ${spring-boot.formatted-version} 7 | -------------------------------------------------------------------------------- /movie-ui/src/main/resources/static/app.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | $('.message').on('click', function() { 3 | $(this).closest('.message').transition('fade') 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /movie-ui/src/main/resources/templates/movieDetail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Movies-Api 6 | 7 | 8 | 9 | 10 | 11 |
12 | 15 | 16 |
17 |
18 |
19 | 20 |
21 |
22 |
23 |
Imdb
24 | 25 |

26 |
Title
27 | 28 |

29 |
Year
30 | 31 |

32 |
Released
33 | 34 |

35 |
Imdb Rating
36 | 37 |

38 |
Genre
39 | 40 |

41 |
Runtime
42 | 43 |

44 |
Director
45 | 46 |

47 |
Writer
48 | 49 |

50 |
Actors
51 | 52 |

53 |
Plot
54 | 55 |

56 |
Language
57 | 58 |

59 |
Country
60 | 61 |

62 |
Awards
63 | 64 |

65 |
66 |
67 |
68 |
69 |
70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /movie-ui/src/main/resources/templates/movies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Movies-UI 6 | 7 | 8 | 9 | 10 | 11 |
12 | 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | 31 | 43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /movie-ui/src/test/java/com/ivanfranchin/movieui/MovieUiApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.movieui; 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 MovieUiApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /opensearch/movies-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "index": { 4 | "max_ngram_diff": 20 5 | }, 6 | "analysis": { 7 | "analyzer": { 8 | "my_analyzer": { 9 | "tokenizer": "standard", 10 | "filter": [ 11 | "lowercase", 12 | "edge_ngram_filter" 13 | ] 14 | }, 15 | "my_search_analyzer": { 16 | "tokenizer": "standard", 17 | "filter": [ 18 | "lowercase" 19 | ] 20 | } 21 | }, 22 | "filter": { 23 | "edge_ngram_filter": { 24 | "type": "edge_ngram", 25 | "min_gram": 1, 26 | "max_gram": 20 27 | } 28 | } 29 | } 30 | }, 31 | "mappings": { 32 | "properties": { 33 | "imdb": { 34 | "type": "keyword" 35 | }, 36 | "title": { 37 | "type": "text", 38 | "analyzer": "my_analyzer", 39 | "search_analyzer": "my_search_analyzer" 40 | }, 41 | "poster": { 42 | "type": "text" 43 | }, 44 | "year": { 45 | "type": "text" 46 | }, 47 | "released": { 48 | "type": "text" 49 | }, 50 | "imdbRating": { 51 | "type": "text" 52 | }, 53 | "genre": { 54 | "type": "text" 55 | }, 56 | "runtime": { 57 | "type": "text" 58 | }, 59 | "director": { 60 | "type": "text" 61 | }, 62 | "writer": { 63 | "type": "text" 64 | }, 65 | "actors": { 66 | "type": "text" 67 | }, 68 | "plot": { 69 | "type": "text" 70 | }, 71 | "language": { 72 | "type": "text" 73 | }, 74 | "country": { 75 | "type": "text" 76 | }, 77 | "awards": { 78 | "type": "text" 79 | }, 80 | "createdAt": { 81 | "type": "date", 82 | "format": "strict_date_optional_time||epoch_second" 83 | } 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /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-opensearch-s3-secretsmanager 13 | 1.0.0 14 | pom 15 | springboot-aws-localstack-opensearch-s3-secretsmanager 16 | Demo project for Spring Boot 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 21 32 | 33 | 34 | movie-api 35 | movie-ui 36 | 37 | 38 | -------------------------------------------------------------------------------- /remove-docker-images.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker rmi ivanfranchin/movie-api:1.0.0 4 | docker rmi ivanfranchin/movie-ui:1.0.0 5 | --------------------------------------------------------------------------------