├── .github └── FUNDING.yml ├── .gitignore ├── README.md ├── documentation ├── demo-admin.gif ├── demo-user-github.gif ├── project-diagram.excalidraw └── project-diagram.jpeg ├── init-environment.sh ├── init-keycloak.sh ├── movies-api ├── .mvn │ └── wrapper │ │ └── maven-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── moviesapi │ │ │ ├── MoviesApiApplication.java │ │ │ ├── config │ │ │ ├── ErrorAttributesConfig.java │ │ │ └── SwaggerConfig.java │ │ │ ├── movie │ │ │ ├── MovieRepository.java │ │ │ ├── MovieService.java │ │ │ ├── MoviesController.java │ │ │ ├── dto │ │ │ │ ├── AddCommentRequest.java │ │ │ │ ├── CreateMovieRequest.java │ │ │ │ └── MovieDto.java │ │ │ ├── exception │ │ │ │ └── MovieNotFoundException.java │ │ │ ├── mapper │ │ │ │ └── MovieDtoMapper.java │ │ │ └── model │ │ │ │ └── Movie.java │ │ │ ├── security │ │ │ ├── CorsConfig.java │ │ │ ├── JwtAuthConverter.java │ │ │ ├── JwtAuthConverterProperties.java │ │ │ └── SecurityConfig.java │ │ │ └── userextra │ │ │ ├── UserExtraController.java │ │ │ ├── UserExtraRepository.java │ │ │ ├── UserExtraService.java │ │ │ ├── dto │ │ │ ├── UpdateMovieRequest.java │ │ │ └── UserExtraRequest.java │ │ │ ├── exception │ │ │ └── UserExtraNotFoundException.java │ │ │ └── model │ │ │ └── UserExtra.java │ └── resources │ │ ├── application.properties │ │ └── banner.txt │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── moviesapi │ └── MoviesApiApplicationTests.java ├── movies-ui ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── images │ │ └── movie-poster.jpg │ ├── index.html │ ├── manifest.json │ └── robots.txt └── src │ ├── App.js │ ├── Constants.js │ ├── components │ ├── home │ │ ├── Home.js │ │ ├── MovieCard.js │ │ └── MovieList.js │ ├── misc │ │ ├── ConfirmationModal.js │ │ ├── Helpers.js │ │ ├── MoviesApi.js │ │ ├── Navbar.js │ │ ├── OmdbApi.js │ │ └── PrivateRoute.js │ ├── movie │ │ ├── MovieCommentForm.js │ │ ├── MovieComments.js │ │ └── MovieDetail.js │ ├── movies │ │ ├── MoviesForm.js │ │ ├── MoviesPage.js │ │ └── MoviesTable.js │ ├── settings │ │ └── UserSettings.js │ └── wizard │ │ ├── CompleteStep.js │ │ ├── FormStep.js │ │ ├── MovieWizard.js │ │ └── SearchStep.js │ ├── index.css │ ├── index.js │ ├── reportWebVitals.js │ └── setupTests.js ├── scripts └── my-functions.sh └── shutdown-environment.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ivangfr 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### STS ### 7 | .apt_generated 8 | .classpath 9 | .factorypath 10 | .project 11 | .settings 12 | .springBeans 13 | .sts4-cache 14 | 15 | ### IntelliJ IDEA ### 16 | .idea 17 | *.iws 18 | *.iml 19 | *.ipr 20 | 21 | ### NetBeans ### 22 | /nbproject/private/ 23 | /nbbuild/ 24 | /dist/ 25 | /nbdist/ 26 | /.nb-gradle/ 27 | build/ 28 | !**/src/main/**/build/ 29 | !**/src/test/**/build/ 30 | 31 | ### VS Code ### 32 | .vscode/ 33 | 34 | ## --- 35 | ## React project 36 | 37 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 38 | 39 | # dependencies 40 | node_modules/ 41 | /.pnp 42 | .pnp.js 43 | 44 | # testing 45 | coverage/ 46 | 47 | # production 48 | build/ 49 | 50 | # misc 51 | .env.local 52 | .env.development.local 53 | .env.test.local 54 | .env.production.local 55 | 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | 60 | .eslintcache 61 | 62 | ### MAC OS ### 63 | *.DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # springboot-react-keycloak 2 | 3 | The goal of this project is to secure `movies-app` using [`Keycloak`](https://www.keycloak.org/)(with PKCE). `movies-app` consists of two applications: one is a [Spring Boot](https://docs.spring.io/spring-boot/index.html) Rest API called `movies-api` and another is a [React](https://react.dev/) application called `movies-ui`. 4 | 5 | ## Proof-of-Concepts & Articles 6 | 7 | On [ivangfr.github.io](https://ivangfr.github.io), I have compiled my Proof-of-Concepts (PoCs) and articles. You can easily search for the technology you are interested in by using the filter. Who knows, perhaps I have already implemented a PoC or written an article about what you are looking for. 8 | 9 | ## Additional Readings 10 | 11 | - \[**Medium**\] [**Implementing a Full Stack Web App using Spring-Boot and React**](https://medium.com/@ivangfr/implementing-a-full-stack-web-app-using-spring-boot-and-react-7db598df4452) 12 | - \[**Medium**\] [**Using Keycloak to secure a Full Stack Web App implemented with Spring-Boot and React**](https://medium.com/@ivangfr/using-keycloak-to-secure-a-full-stack-web-app-implemented-with-spring-boot-and-react-6b2d80fc5c12) 13 | - \[**Medium**\] [**Implementing and Securing a Simple Spring Boot REST API with Keycloak**](https://medium.com/@ivangfr/how-to-secure-a-spring-boot-app-with-keycloak-5a931ee12c5a) 14 | - \[**Medium**\] [**Implementing and Securing a Simple Spring Boot UI (Thymeleaf + RBAC) with Keycloak**](https://medium.com/@ivangfr/how-to-secure-a-simple-spring-boot-ui-thymeleaf-rbac-with-keycloak-ba9f30b9cb2b) 15 | - \[**Medium**\] [**Implementing and Securing a Spring Boot GraphQL API with Keycloak**](https://medium.com/@ivangfr/implementing-and-securing-a-spring-boot-graphql-api-with-keycloak-c461c86e3972) 16 | - \[**Medium**\] [**Setting Up OpenLDAP With Keycloak For User Federation**](https://medium.com/@ivangfr/setting-up-openldap-with-keycloak-for-user-federation-82c643b3a0e6) 17 | - \[**Medium**\] [**Integrating GitHub as a Social Identity Provider in Keycloak**](https://medium.com/@ivangfr/integrating-github-as-a-social-identity-provider-in-keycloak-982f521a622f) 18 | - \[**Medium**\] [**Integrating Google as a Social Identity Provider in Keycloak**](https://medium.com/@ivangfr/integrating-google-as-a-social-identity-provider-in-keycloak-c905577ec499) 19 | - \[**Medium**\] [**Building a Single Spring Boot App with Keycloak or Okta as IdP: Introduction**](https://medium.com/@ivangfr/building-a-single-spring-boot-app-with-keycloak-or-okta-as-idp-introduction-2814a4829aed) 20 | 21 | ## Project diagram 22 | 23 | ![project-diagram](documentation/project-diagram.jpeg) 24 | 25 | ## Applications 26 | 27 | - ### movies-api 28 | 29 | `Spring Boot` Web Java backend application that exposes a REST API to manage **movies**. Its secured endpoints can just be accessed if an access token (JWT) issued by `Keycloak` is provided. 30 | 31 | `movies-api` stores its data in a [`Mongo`](https://www.mongodb.com/) database. 32 | 33 | `movie-api` has the following endpoints: 34 | 35 | | Endpoint | Secured | Roles | 36 | |-------------------------------------------------------------------|---------|----------------------------------| 37 | | `GET /api/userextras/me` | Yes | `MOVIES_ADMIN` and `MOVIES_USER` | 38 | | `POST /api/userextras/me -d {avatar}` | Yes | `MOVIES_ADMIN` and `MOVIES_USER` | 39 | | `GET /api/movies` | No | | 40 | | `GET /api/movies/{imdbId}` | No | | 41 | | `POST /api/movies -d {"imdb","title","director","year","poster"}` | Yes | `MOVIES_ADMIN` | 42 | | `DELETE /api/movies/{imdbId}` | Yes | `MOVIES_ADMIN` | 43 | | `POST /api/movies/{imdbId}/comments -d {"text"}` | Yes | `MOVIES_ADMIN` and `MOVIES_USER` | 44 | 45 | - ### movies-ui 46 | 47 | `React` frontend application where `users` can see and comment movies and `admins` can manage movies. To access the application, `user` / `admin` must login using his/her username and password. Those credentials are managed by `Keycloak`. All the requests from `movies-ui` to secured endpoints in `movies-api` include an access token (JWT) that is generated when `user` / `admin` logs in. 48 | 49 | `movies-ui` uses [`Semantic UI React`](https://react.semantic-ui.com/) as CSS-styled framework. 50 | 51 | ## Prerequisites 52 | 53 | - [`Java 21`](https://www.oracle.com/java/technologies/downloads/#java21) or higher; 54 | - [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) 55 | - A containerization tool (e.g., [`Docker`](https://www.docker.com), [`Podman`](https://podman.io), etc.) 56 | - [`jq`](https://jqlang.github.io/jq/) 57 | - [`OMDb API`](https://www.omdbapi.com/) KEY 58 | 59 | To use the `Wizard` option to search and add a movie, we need to get an API KEY from OMDb API. To do this, access https://www.omdbapi.com/apikey.aspx and follow the steps provided by the website. 60 | 61 | Once we have the API KEY, create a file called `.env.local` in the `springboot-react-keycloak/movies-ui` folder with the following content: 62 | ```text 63 | REACT_APP_OMDB_API_KEY= 64 | ``` 65 | 66 | ## PKCE 67 | 68 | As `Keycloak` supports [`PKCE`](https://datatracker.ietf.org/doc/html/rfc7636) (`Proof Key for Code Exchange`) since version `7.0.0`, we are using it in this project. 69 | 70 | ## Start Environment 71 | 72 | In a terminal, navigate to the `springboot-react-keycloak` root folder and run: 73 | ```bash 74 | ./init-environment.sh 75 | ``` 76 | 77 | ## Initialize Keycloak 78 | 79 | In a terminal and inside the `springboot-react-keycloak` root folder run: 80 | ```bash 81 | ./init-keycloak.sh 82 | ``` 83 | 84 | This script will: 85 | - create `company-services` realm; 86 | - disable the required action `Verify Profile`; 87 | - create `movies-app` client; 88 | - create the client role `MOVIES_USER` for the `movies-app` client; 89 | - create the client role `MOVIES_ADMIN` for the `movies-app` client; 90 | - create `USERS` group; 91 | - create `ADMINS` group; 92 | - add `USERS` group as realm default group; 93 | - assign `MOVIES_USER` client role to `USERS` group; 94 | - assign `MOVIES_USER` and `MOVIES_ADMIN` client roles to `ADMINS` group; 95 | - create `user` user; 96 | - assign `USERS` group to user; 97 | - create `admin` user; 98 | - assign `ADMINS` group to admin. 99 | 100 | ## Running movies-app using Maven & Npm 101 | 102 | - **movies-api** 103 | 104 | - Open a terminal and navigate to the `springboot-react-keycloak/movies-api` folder; 105 | 106 | - Run the following `Maven` command to start the application: 107 | ```bash 108 | ./mvnw clean spring-boot:run -Dspring-boot.run.jvmArguments="-Dserver.port=9080" 109 | ``` 110 | 111 | - We can also configure **Social Identity Providers** such as, `GitHub`, `Google`, `Facebook` and `Instagram`. I've written two articles in **Medium** where I explain step-by-step how to integrate [GitHub](https://medium.com/@ivangfr/integrating-github-as-a-social-identity-provider-in-keycloak-982f521a622f) and [Google](https://medium.com/@ivangfr/integrating-google-as-a-social-identity-provider-in-keycloak-c905577ec499). 112 | 113 | - **movies-ui** 114 | 115 | - Open another terminal and navigate to the `springboot-react-keycloak/movies-ui` folder; 116 | 117 | - Run the command below if you are running the application for the first time: 118 | ```bash 119 | npm install 120 | ``` 121 | 122 | - Run the `npm` command below to start the application: 123 | ```bash 124 | npm start 125 | ``` 126 | 127 | ## Applications URLs 128 | 129 | | Application | URL | Credentials | 130 | |-------------|---------------------------------------|---------------------------------------| 131 | | movie-api | http://localhost:9080/swagger-ui.html | [Access Token](#getting-access-token) | 132 | | movie-ui | http://localhost:3000 | `admin/admin` or `user/user` | 133 | | Keycloak | http://localhost:8080 | `admin/admin` | 134 | 135 | ## Demo 136 | 137 | - The gif below shows an `admin` logging in and adding one movie using the wizard feature: 138 | 139 | ![demo-admin](documentation/demo-admin.gif) 140 | 141 | - The gif below shows a `user` logging in using his Github account; then he changes his avatar and comment on a movie: 142 | 143 | ![demo-user-github](documentation/demo-user-github.gif) 144 | 145 | ## Testing movies-api endpoints 146 | 147 | We can manage movies by directly accessing `movies-api` endpoints using the Swagger website or `curl`. For the secured endpoints like `POST /api/movies`, `PUT /api/movies/{id}`, `DELETE /api/movies/{id}`, etc, we need to inform an access token issued by `Keycloak`. 148 | 149 | ### Getting Access Token 150 | 151 | - Open a terminal. 152 | 153 | - Run the following commands to get the access token: 154 | ```bash 155 | ACCESS_TOKEN="$(curl -s -X POST \ 156 | "http://localhost:8080/realms/company-services/protocol/openid-connect/token" \ 157 | -H "Content-Type: application/x-www-form-urlencoded" \ 158 | -d "username=admin" \ 159 | -d "password=admin" \ 160 | -d "grant_type=password" \ 161 | -d "client_id=movies-app" | jq -r .access_token)" 162 | 163 | echo $ACCESS_TOKEN 164 | ``` 165 | > **Note**: In [jwt.io](https://jwt.io), we can decode and verify the `JWT` access token. 166 | 167 | ### Calling movies-api endpoints using curl 168 | 169 | - Trying to add a movie without access token: 170 | ```bash 171 | curl -i -X POST "http://localhost:9080/api/movies" \ 172 | -H "Content-Type: application/json" \ 173 | -d '{ "imdbId": "tt5580036", "title": "I, Tonya", "director": "Craig Gillespie", "year": 2017, "poster": "https://m.media-amazon.com/images/M/MV5BMjI5MDY1NjYzMl5BMl5BanBnXkFtZTgwNjIzNDAxNDM@._V1_SX300.jpg"}' 174 | ``` 175 | 176 | It should return: 177 | ```text 178 | HTTP/1.1 401 179 | ``` 180 | 181 | - Trying again to add a movie, now with access token (obtained at [getting-access-token](#getting-access-token)): 182 | ```bash 183 | curl -i -X POST "http://localhost:9080/api/movies" \ 184 | -H "Authorization: Bearer $ACCESS_TOKEN" \ 185 | -H "Content-Type: application/json" \ 186 | -d '{ "imdbId": "tt5580036", "title": "I, Tonya", "director": "Craig Gillespie", "year": 2017, "poster": "https://m.media-amazon.com/images/M/MV5BMjI5MDY1NjYzMl5BMl5BanBnXkFtZTgwNjIzNDAxNDM@._V1_SX300.jpg"}' 187 | ``` 188 | 189 | It should return: 190 | ```text 191 | HTTP/1.1 201 192 | { 193 | "imdbId": "tt5580036", 194 | "title": "I, Tonya", 195 | "director": "Craig Gillespie", 196 | "year": "2017", 197 | "poster": "https://m.media-amazon.com/images/M/MV5BMjI5MDY1NjYzMl5BMl5BanBnXkFtZTgwNjIzNDAxNDM@._V1_SX300.jpg", 198 | "comments": [] 199 | } 200 | ``` 201 | 202 | - Getting the list of movies. This endpoint does not require access token: 203 | ```bash 204 | curl -i http://localhost:9080/api/movies 205 | ``` 206 | 207 | It should return: 208 | ```text 209 | HTTP/1.1 200 210 | [ 211 | { 212 | "imdbId": "tt5580036", 213 | "title": "I, Tonya", 214 | "director": "Craig Gillespie", 215 | "year": "2017", 216 | "poster": "https://m.media-amazon.com/images/M/MV5BMjI5MDY1NjYzMl5BMl5BanBnXkFtZTgwNjIzNDAxNDM@._V1_SX300.jpg", 217 | "comments": [] 218 | } 219 | ] 220 | ``` 221 | 222 | ### Calling movies-api endpoints using Swagger 223 | 224 | - Access `movies-api` Swagger website, http://localhost:9080/swagger-ui.html. 225 | 226 | - Click `Authorize` button. 227 | 228 | - In the form that opens, paste the `access token` (obtained at [getting-access-token](#getting-access-token)) in the `Value` field. Then, click `Authorize` and `Close` to finalize. 229 | 230 | - Done! We can now access the secured endpoints. 231 | 232 | ## Useful Commands 233 | 234 | - **MongoDB** 235 | 236 | List all movies: 237 | ```bash 238 | docker exec -it mongodb mongosh moviesdb 239 | db.movies.find() 240 | ``` 241 | > Type `exit` to exit of MongoDB shell. 242 | 243 | ## Shutdown 244 | 245 | - To stop `movies-api` and `movies-ui`, go to the terminals where they are running and press `Ctrl+C`; 246 | 247 | - To stop and remove docker containers, network and volumes, go to a terminal and, inside the `springboot-react-keycloak` root folder, run the command below: 248 | ```bash 249 | ./shutdown-environment.sh 250 | ``` 251 | 252 | ## How to upgrade movies-ui dependencies to latest version 253 | 254 | - In a terminal, make sure you are in the `springboot-react-keycloak/movies-ui` folder; 255 | 256 | - Run the following commands: 257 | ```bash 258 | npm upgrade 259 | npm i -g npm-check-updates 260 | ncu -u 261 | npm install 262 | ``` 263 | -------------------------------------------------------------------------------- /documentation/demo-admin.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-react-keycloak/ff77df650683377b1684b398f5639342851beead/documentation/demo-admin.gif -------------------------------------------------------------------------------- /documentation/demo-user-github.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-react-keycloak/ff77df650683377b1684b398f5639342851beead/documentation/demo-user-github.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": 356, 9 | "versionNonce": 1381161935, 10 | "isDeleted": false, 11 | "id": "LF8GW9IPL_GFv-IztvS9e", 12 | "fillStyle": "hachure", 13 | "strokeWidth": 1, 14 | "strokeStyle": "solid", 15 | "roughness": 1, 16 | "opacity": 100, 17 | "angle": 0, 18 | "x": 695.4324866736397, 19 | "y": 123.47551845425096, 20 | "strokeColor": "#000000", 21 | "backgroundColor": "#4c6ef5", 22 | "width": 209.18356323242188, 23 | "height": 99.67071533203125, 24 | "seed": 1878377958, 25 | "groupIds": [], 26 | "roundness": { 27 | "type": 3 28 | }, 29 | "boundElements": [ 30 | { 31 | "type": "text", 32 | "id": "_OetYLRC4Z5TgAYd9XEfr" 33 | }, 34 | { 35 | "id": "yBKKo2h4VEy5ZKYYClSMJ", 36 | "type": "arrow" 37 | }, 38 | { 39 | "id": "aoE5utJJiRd6nbLGf5SZL", 40 | "type": "arrow" 41 | }, 42 | { 43 | "id": "piKHBb0Cd9VEOt27_Txy7", 44 | "type": "arrow" 45 | }, 46 | { 47 | "id": "DsRU6l6oqgauSpfJP0FFX", 48 | "type": "arrow" 49 | }, 50 | { 51 | "id": "kW2uU-UIG2e89viZqg-Yl", 52 | "type": "arrow" 53 | } 54 | ], 55 | "updated": 1682156689500, 56 | "link": null, 57 | "locked": false 58 | }, 59 | { 60 | "type": "text", 61 | "version": 334, 62 | "versionNonce": 685674598, 63 | "isDeleted": false, 64 | "id": "_OetYLRC4Z5TgAYd9XEfr", 65 | "fillStyle": "hachure", 66 | "strokeWidth": 1, 67 | "strokeStyle": "solid", 68 | "roughness": 1, 69 | "opacity": 100, 70 | "angle": 0, 71 | "x": 732.698302408503, 72 | "y": 156.51087612026657, 73 | "strokeColor": "#000000", 74 | "backgroundColor": "transparent", 75 | "width": 134.6519317626953, 76 | "height": 33.6, 77 | "seed": 542041697, 78 | "groupIds": [], 79 | "roundness": null, 80 | "boundElements": [], 81 | "updated": 1678628174467, 82 | "link": null, 83 | "locked": false, 84 | "fontSize": 28, 85 | "fontFamily": 1, 86 | "text": "movies-api", 87 | "textAlign": "center", 88 | "verticalAlign": "middle", 89 | "containerId": "LF8GW9IPL_GFv-IztvS9e", 90 | "originalText": "movies-api", 91 | "lineHeight": 1.2, 92 | "baseline": 24 93 | }, 94 | { 95 | "type": "rectangle", 96 | "version": 431, 97 | "versionNonce": 1851576545, 98 | "isDeleted": false, 99 | "id": "nPUKoIL6nC8L6dHS8GRdd", 100 | "fillStyle": "hachure", 101 | "strokeWidth": 1, 102 | "strokeStyle": "solid", 103 | "roughness": 1, 104 | "opacity": 100, 105 | "angle": 0, 106 | "x": 989.3323279822334, 107 | "y": 124.06385158413377, 108 | "strokeColor": "#000000", 109 | "backgroundColor": "#15aabf", 110 | "width": 209.18356323242188, 111 | "height": 99.67071533203125, 112 | "seed": 508565798, 113 | "groupIds": [], 114 | "roundness": { 115 | "type": 3 116 | }, 117 | "boundElements": [ 118 | { 119 | "type": "text", 120 | "id": "dMIYD8Ri0limcILL45dDK" 121 | }, 122 | { 123 | "id": "kW2uU-UIG2e89viZqg-Yl", 124 | "type": "arrow" 125 | } 126 | ], 127 | "updated": 1682156687011, 128 | "link": null, 129 | "locked": false 130 | }, 131 | { 132 | "type": "text", 133 | "version": 426, 134 | "versionNonce": 572015610, 135 | "isDeleted": false, 136 | "id": "dMIYD8Ri0limcILL45dDK", 137 | "fillStyle": "hachure", 138 | "strokeWidth": 1, 139 | "strokeStyle": "solid", 140 | "roughness": 1, 141 | "opacity": 100, 142 | "angle": 0, 143 | "x": 1033.038130899714, 144 | "y": 157.09920925014939, 145 | "strokeColor": "#000000", 146 | "backgroundColor": "transparent", 147 | "width": 121.77195739746094, 148 | "height": 33.6, 149 | "seed": 446625633, 150 | "groupIds": [], 151 | "roundness": null, 152 | "boundElements": [], 153 | "updated": 1678628174468, 154 | "link": null, 155 | "locked": false, 156 | "fontSize": 28, 157 | "fontFamily": 1, 158 | "text": "MongoDB", 159 | "textAlign": "center", 160 | "verticalAlign": "middle", 161 | "containerId": "nPUKoIL6nC8L6dHS8GRdd", 162 | "originalText": "MongoDB", 163 | "lineHeight": 1.2, 164 | "baseline": 24 165 | }, 166 | { 167 | "type": "rectangle", 168 | "version": 738, 169 | "versionNonce": 1437912225, 170 | "isDeleted": false, 171 | "id": "htH4DvpAlw_lK0WCfkn_y", 172 | "fillStyle": "hachure", 173 | "strokeWidth": 1, 174 | "strokeStyle": "solid", 175 | "roughness": 1, 176 | "opacity": 100, 177 | "angle": 0, 178 | "x": 290.11012268066406, 179 | "y": 125.38711547851562, 180 | "strokeColor": "#000000", 181 | "backgroundColor": "#be4bdb", 182 | "width": 209.18356323242188, 183 | "height": 99.67071533203125, 184 | "seed": 209585254, 185 | "groupIds": [], 186 | "roundness": { 187 | "type": 3 188 | }, 189 | "boundElements": [ 190 | { 191 | "type": "text", 192 | "id": "2X2Ld8TDu-NPJPkdyJl5g" 193 | }, 194 | { 195 | "id": "yBKKo2h4VEy5ZKYYClSMJ", 196 | "type": "arrow" 197 | }, 198 | { 199 | "id": "qEWWPxiXVvqSeK9IF7lW6", 200 | "type": "arrow" 201 | }, 202 | { 203 | "id": "Sp9Xg4WQENiW_MemWMvHF", 204 | "type": "arrow" 205 | }, 206 | { 207 | "id": "POtOtVS0wydJOmbpRxpmR", 208 | "type": "arrow" 209 | }, 210 | { 211 | "id": "nygJVkGwIGzzo_tBqH1MG", 212 | "type": "arrow" 213 | }, 214 | { 215 | "id": "U8jVXWMsTfFs21OLya7-G", 216 | "type": "arrow" 217 | } 218 | ], 219 | "updated": 1682156689500, 220 | "link": null, 221 | "locked": false 222 | }, 223 | { 224 | "type": "text", 225 | "version": 738, 226 | "versionNonce": 2099360954, 227 | "isDeleted": false, 228 | "id": "2X2Ld8TDu-NPJPkdyJl5g", 229 | "fillStyle": "hachure", 230 | "strokeWidth": 1, 231 | "strokeStyle": "solid", 232 | "roughness": 1, 233 | "opacity": 100, 234 | "angle": 0, 235 | "x": 337.27393341064453, 236 | "y": 158.42247314453124, 237 | "strokeColor": "#000000", 238 | "backgroundColor": "transparent", 239 | "width": 114.85594177246094, 240 | "height": 33.6, 241 | "seed": 200323745, 242 | "groupIds": [], 243 | "roundness": null, 244 | "boundElements": [], 245 | "updated": 1678628174468, 246 | "link": null, 247 | "locked": false, 248 | "fontSize": 28, 249 | "fontFamily": 1, 250 | "text": "Keycloak", 251 | "textAlign": "center", 252 | "verticalAlign": "middle", 253 | "containerId": "htH4DvpAlw_lK0WCfkn_y", 254 | "originalText": "Keycloak", 255 | "lineHeight": 1.2, 256 | "baseline": 24 257 | }, 258 | { 259 | "type": "rectangle", 260 | "version": 920, 261 | "versionNonce": 2025477953, 262 | "isDeleted": false, 263 | "id": "NKmNZxYxWMCKh3prRiPwX", 264 | "fillStyle": "hachure", 265 | "strokeWidth": 1, 266 | "strokeStyle": "solid", 267 | "roughness": 1, 268 | "opacity": 100, 269 | "angle": 0, 270 | "x": -11.5384605919852, 271 | "y": 121.84813808315721, 272 | "strokeColor": "#000000", 273 | "backgroundColor": "#82c91e", 274 | "width": 209.18356323242188, 275 | "height": 99.67071533203125, 276 | "seed": 1526615674, 277 | "groupIds": [], 278 | "roundness": { 279 | "type": 3 280 | }, 281 | "boundElements": [ 282 | { 283 | "type": "text", 284 | "id": "C9YTYTHGgwpszLfgIFDTi" 285 | }, 286 | { 287 | "id": "U8jVXWMsTfFs21OLya7-G", 288 | "type": "arrow" 289 | } 290 | ], 291 | "updated": 1682156671663, 292 | "link": null, 293 | "locked": false 294 | }, 295 | { 296 | "type": "text", 297 | "version": 942, 298 | "versionNonce": 1388486566, 299 | "isDeleted": false, 300 | "id": "C9YTYTHGgwpszLfgIFDTi", 301 | "fillStyle": "hachure", 302 | "strokeWidth": 1, 303 | "strokeStyle": "solid", 304 | "roughness": 1, 305 | "opacity": 100, 306 | "angle": 0, 307 | "x": 32.23734202031949, 308 | "y": 154.88349574917282, 309 | "strokeColor": "#000000", 310 | "backgroundColor": "transparent", 311 | "width": 121.6319580078125, 312 | "height": 33.6, 313 | "seed": 672921103, 314 | "groupIds": [], 315 | "roundness": null, 316 | "boundElements": [], 317 | "updated": 1678628154884, 318 | "link": null, 319 | "locked": false, 320 | "fontSize": 28, 321 | "fontFamily": 1, 322 | "text": "Postgres", 323 | "textAlign": "center", 324 | "verticalAlign": "middle", 325 | "containerId": "NKmNZxYxWMCKh3prRiPwX", 326 | "originalText": "Postgres", 327 | "lineHeight": 1.2, 328 | "baseline": 24 329 | }, 330 | { 331 | "type": "rectangle", 332 | "version": 405, 333 | "versionNonce": 1918741946, 334 | "isDeleted": false, 335 | "id": "-fBgLVr8TGW8aaNMK731H", 336 | "fillStyle": "hachure", 337 | "strokeWidth": 1, 338 | "strokeStyle": "solid", 339 | "roughness": 1, 340 | "opacity": 100, 341 | "angle": 0, 342 | "x": 475.4608939313449, 343 | "y": 383.5009534638592, 344 | "strokeColor": "#000000", 345 | "backgroundColor": "#868e96", 346 | "width": 209.18356323242188, 347 | "height": 99.67071533203125, 348 | "seed": 743212966, 349 | "groupIds": [], 350 | "roundness": { 351 | "type": 3 352 | }, 353 | "boundElements": [ 354 | { 355 | "type": "text", 356 | "id": "ibIuQlYWMa9dg_rumvKxK" 357 | }, 358 | { 359 | "id": "DsRU6l6oqgauSpfJP0FFX", 360 | "type": "arrow" 361 | }, 362 | { 363 | "id": "qEWWPxiXVvqSeK9IF7lW6", 364 | "type": "arrow" 365 | }, 366 | { 367 | "id": "Sp9Xg4WQENiW_MemWMvHF", 368 | "type": "arrow" 369 | }, 370 | { 371 | "id": "rrKSZflKpeluTvYOF6GrZ", 372 | "type": "arrow" 373 | }, 374 | { 375 | "id": "u_uFh-baXw7JZVs8vLiR_", 376 | "type": "arrow" 377 | }, 378 | { 379 | "id": "aoE5utJJiRd6nbLGf5SZL", 380 | "type": "arrow" 381 | } 382 | ], 383 | "updated": 1678628177520, 384 | "link": null, 385 | "locked": false 386 | }, 387 | { 388 | "type": "text", 389 | "version": 384, 390 | "versionNonce": 437747834, 391 | "isDeleted": false, 392 | "id": "ibIuQlYWMa9dg_rumvKxK", 393 | "fillStyle": "hachure", 394 | "strokeWidth": 1, 395 | "strokeStyle": "solid", 396 | "roughness": 1, 397 | "opacity": 100, 398 | "angle": 0, 399 | "x": 521.0147040509738, 400 | "y": 416.53631112987483, 401 | "strokeColor": "#000000", 402 | "backgroundColor": "transparent", 403 | "width": 118.07594299316406, 404 | "height": 33.6, 405 | "seed": 781345565, 406 | "groupIds": [], 407 | "roundness": null, 408 | "boundElements": [], 409 | "updated": 1678628174469, 410 | "link": null, 411 | "locked": false, 412 | "fontSize": 28, 413 | "fontFamily": 1, 414 | "text": "movies-ui", 415 | "textAlign": "center", 416 | "verticalAlign": "middle", 417 | "containerId": "-fBgLVr8TGW8aaNMK731H", 418 | "originalText": "movies-ui", 419 | "lineHeight": 1.2, 420 | "baseline": 24 421 | }, 422 | { 423 | "type": "ellipse", 424 | "version": 207, 425 | "versionNonce": 2137913786, 426 | "isDeleted": false, 427 | "id": "flM-p46Jb3LUEX1XCxWf1", 428 | "fillStyle": "hachure", 429 | "strokeWidth": 2, 430 | "strokeStyle": "solid", 431 | "roughness": 1, 432 | "opacity": 100, 433 | "angle": 0, 434 | "x": 161.03427344794648, 435 | "y": 429.09998300487484, 436 | "strokeColor": "#000000", 437 | "backgroundColor": "transparent", 438 | "width": 26.930389404296875, 439 | "height": 27.545562744140625, 440 | "seed": 427129766, 441 | "groupIds": [ 442 | "k93ihkNXC8u2CtHzxSadx" 443 | ], 444 | "roundness": { 445 | "type": 2 446 | }, 447 | "boundElements": [], 448 | "updated": 1678628199124, 449 | "link": null, 450 | "locked": false 451 | }, 452 | { 453 | "type": "line", 454 | "version": 228, 455 | "versionNonce": 431665786, 456 | "isDeleted": false, 457 | "id": "EbiGQ1Bsju1j3fJU9VTt0", 458 | "fillStyle": "hachure", 459 | "strokeWidth": 2, 460 | "strokeStyle": "solid", 461 | "roughness": 1, 462 | "opacity": 100, 463 | "angle": 0, 464 | "x": 172.47210914130585, 465 | "y": 457.10062387401547, 466 | "strokeColor": "#000000", 467 | "backgroundColor": "transparent", 468 | "width": 0.473419189453125, 469 | "height": 40.3687744140625, 470 | "seed": 1909659366, 471 | "groupIds": [ 472 | "k93ihkNXC8u2CtHzxSadx" 473 | ], 474 | "roundness": { 475 | "type": 2 476 | }, 477 | "boundElements": [], 478 | "updated": 1678628199124, 479 | "link": null, 480 | "locked": false, 481 | "startBinding": null, 482 | "endBinding": null, 483 | "lastCommittedPoint": null, 484 | "startArrowhead": null, 485 | "endArrowhead": null, 486 | "points": [ 487 | [ 488 | 0, 489 | 0 490 | ], 491 | [ 492 | -0.473419189453125, 493 | 40.3687744140625 494 | ] 495 | ] 496 | }, 497 | { 498 | "type": "line", 499 | "version": 179, 500 | "versionNonce": 1668436794, 501 | "isDeleted": false, 502 | "id": "1ekBPFngOlVFkHRQdx_fs", 503 | "fillStyle": "hachure", 504 | "strokeWidth": 2, 505 | "strokeStyle": "solid", 506 | "roughness": 1, 507 | "opacity": 100, 508 | "angle": 0, 509 | "x": 172.17660743232148, 510 | "y": 498.9042737763592, 511 | "strokeColor": "#000000", 512 | "backgroundColor": "transparent", 513 | "width": 17.21380615234375, 514 | "height": 33.91400146484375, 515 | "seed": 2052803110, 516 | "groupIds": [ 517 | "k93ihkNXC8u2CtHzxSadx" 518 | ], 519 | "roundness": { 520 | "type": 2 521 | }, 522 | "boundElements": [], 523 | "updated": 1678628199124, 524 | "link": null, 525 | "locked": false, 526 | "startBinding": null, 527 | "endBinding": null, 528 | "lastCommittedPoint": null, 529 | "startArrowhead": null, 530 | "endArrowhead": null, 531 | "points": [ 532 | [ 533 | 0, 534 | 0 535 | ], 536 | [ 537 | -17.21380615234375, 538 | 33.91400146484375 539 | ] 540 | ] 541 | }, 542 | { 543 | "type": "line", 544 | "version": 198, 545 | "versionNonce": 2130172922, 546 | "isDeleted": false, 547 | "id": "1uBWzjtbCU7nyF1hJT3-Q", 548 | "fillStyle": "hachure", 549 | "strokeWidth": 2, 550 | "strokeStyle": "solid", 551 | "roughness": 1, 552 | "opacity": 100, 553 | "angle": 0, 554 | "x": 172.29245215888398, 555 | "y": 499.02445199901547, 556 | "strokeColor": "#000000", 557 | "backgroundColor": "transparent", 558 | "width": 12.9422607421875, 559 | "height": 35.16510009765625, 560 | "seed": 33232230, 561 | "groupIds": [ 562 | "k93ihkNXC8u2CtHzxSadx" 563 | ], 564 | "roundness": { 565 | "type": 2 566 | }, 567 | "boundElements": [], 568 | "updated": 1678628199124, 569 | "link": null, 570 | "locked": false, 571 | "startBinding": null, 572 | "endBinding": null, 573 | "lastCommittedPoint": null, 574 | "startArrowhead": null, 575 | "endArrowhead": null, 576 | "points": [ 577 | [ 578 | 0, 579 | 0 580 | ], 581 | [ 582 | 12.9422607421875, 583 | 35.16510009765625 584 | ] 585 | ] 586 | }, 587 | { 588 | "type": "line", 589 | "version": 214, 590 | "versionNonce": 1246645434, 591 | "isDeleted": false, 592 | "id": "68rxeiTLOJcmisjdHZN_M", 593 | "fillStyle": "hachure", 594 | "strokeWidth": 2, 595 | "strokeStyle": "solid", 596 | "roughness": 1, 597 | "opacity": 100, 598 | "angle": 0, 599 | "x": 173.4882834577121, 600 | "y": 474.5986097138592, 601 | "strokeColor": "#000000", 602 | "backgroundColor": "transparent", 603 | "width": 29.445220947265625, 604 | "height": 20.990234375, 605 | "seed": 1912567974, 606 | "groupIds": [ 607 | "k93ihkNXC8u2CtHzxSadx" 608 | ], 609 | "roundness": { 610 | "type": 2 611 | }, 612 | "boundElements": [], 613 | "updated": 1678628199124, 614 | "link": null, 615 | "locked": false, 616 | "startBinding": null, 617 | "endBinding": null, 618 | "lastCommittedPoint": null, 619 | "startArrowhead": null, 620 | "endArrowhead": null, 621 | "points": [ 622 | [ 623 | 0, 624 | 0 625 | ], 626 | [ 627 | 29.445220947265625, 628 | -20.990234375 629 | ] 630 | ] 631 | }, 632 | { 633 | "type": "line", 634 | "version": 253, 635 | "versionNonce": 1796777338, 636 | "isDeleted": false, 637 | "id": "86PwlDW0bQsfE8A6yj0ns", 638 | "fillStyle": "hachure", 639 | "strokeWidth": 2, 640 | "strokeStyle": "solid", 641 | "roughness": 1, 642 | "opacity": 100, 643 | "angle": 0, 644 | "x": 172.59738379950898, 645 | "y": 473.86844613964047, 646 | "strokeColor": "#000000", 647 | "backgroundColor": "transparent", 648 | "width": 25.4169921875, 649 | "height": 9.85821533203125, 650 | "seed": 34960358, 651 | "groupIds": [ 652 | "k93ihkNXC8u2CtHzxSadx" 653 | ], 654 | "roundness": { 655 | "type": 2 656 | }, 657 | "boundElements": [], 658 | "updated": 1678628199124, 659 | "link": null, 660 | "locked": false, 661 | "startBinding": null, 662 | "endBinding": null, 663 | "lastCommittedPoint": null, 664 | "startArrowhead": null, 665 | "endArrowhead": null, 666 | "points": [ 667 | [ 668 | 0, 669 | 0 670 | ], 671 | [ 672 | -25.4169921875, 673 | -9.85821533203125 674 | ] 675 | ] 676 | }, 677 | { 678 | "type": "text", 679 | "version": 184, 680 | "versionNonce": 1520035386, 681 | "isDeleted": false, 682 | "id": "ehCDjz26ruGMW6Q7TDldG", 683 | "fillStyle": "hachure", 684 | "strokeWidth": 2, 685 | "strokeStyle": "solid", 686 | "roughness": 1, 687 | "opacity": 100, 688 | "angle": 0, 689 | "x": 150.11038428779023, 690 | "y": 399.68042734081234, 691 | "strokeColor": "#000000", 692 | "backgroundColor": "transparent", 693 | "width": 50.87995910644531, 694 | "height": 24, 695 | "seed": 1816790822, 696 | "groupIds": [ 697 | "k93ihkNXC8u2CtHzxSadx" 698 | ], 699 | "roundness": null, 700 | "boundElements": [], 701 | "updated": 1678628199124, 702 | "link": null, 703 | "locked": false, 704 | "fontSize": 20, 705 | "fontFamily": 1, 706 | "text": "Admin", 707 | "textAlign": "left", 708 | "verticalAlign": "top", 709 | "containerId": null, 710 | "originalText": "Admin", 711 | "lineHeight": 1.2, 712 | "baseline": 17 713 | }, 714 | { 715 | "type": "ellipse", 716 | "version": 164, 717 | "versionNonce": 1485495526, 718 | "isDeleted": false, 719 | "id": "zYllgBlgP7S7-phqNnnEr", 720 | "fillStyle": "hachure", 721 | "strokeWidth": 2, 722 | "strokeStyle": "solid", 723 | "roughness": 1, 724 | "opacity": 100, 725 | "angle": 0, 726 | "x": 105.6761496686496, 727 | "y": 364.33545663768734, 728 | "strokeColor": "#000000", 729 | "backgroundColor": "transparent", 730 | "width": 26.930389404296875, 731 | "height": 27.545562744140625, 732 | "seed": 1978192826, 733 | "groupIds": [ 734 | "4D1ojplACrlVIaNZ7P0FH" 735 | ], 736 | "roundness": { 737 | "type": 2 738 | }, 739 | "boundElements": [], 740 | "updated": 1678628196712, 741 | "link": null, 742 | "locked": false 743 | }, 744 | { 745 | "type": "line", 746 | "version": 185, 747 | "versionNonce": 36327462, 748 | "isDeleted": false, 749 | "id": "iW_3iMfYwsgECYYtnEK33", 750 | "fillStyle": "hachure", 751 | "strokeWidth": 2, 752 | "strokeStyle": "solid", 753 | "roughness": 1, 754 | "opacity": 100, 755 | "angle": 0, 756 | "x": 117.11398536200898, 757 | "y": 392.33609750682797, 758 | "strokeColor": "#000000", 759 | "backgroundColor": "transparent", 760 | "width": 0.473419189453125, 761 | "height": 40.3687744140625, 762 | "seed": 1875528826, 763 | "groupIds": [ 764 | "4D1ojplACrlVIaNZ7P0FH" 765 | ], 766 | "roundness": { 767 | "type": 2 768 | }, 769 | "boundElements": [], 770 | "updated": 1678628196712, 771 | "link": null, 772 | "locked": false, 773 | "startBinding": null, 774 | "endBinding": null, 775 | "lastCommittedPoint": null, 776 | "startArrowhead": null, 777 | "endArrowhead": null, 778 | "points": [ 779 | [ 780 | 0, 781 | 0 782 | ], 783 | [ 784 | -0.473419189453125, 785 | 40.3687744140625 786 | ] 787 | ] 788 | }, 789 | { 790 | "type": "line", 791 | "version": 136, 792 | "versionNonce": 100227942, 793 | "isDeleted": false, 794 | "id": "8JTNvN86yjteIVYunsqxA", 795 | "fillStyle": "hachure", 796 | "strokeWidth": 2, 797 | "strokeStyle": "solid", 798 | "roughness": 1, 799 | "opacity": 100, 800 | "angle": 0, 801 | "x": 116.8184836530246, 802 | "y": 434.1397474091717, 803 | "strokeColor": "#000000", 804 | "backgroundColor": "transparent", 805 | "width": 17.21380615234375, 806 | "height": 33.91400146484375, 807 | "seed": 1216261434, 808 | "groupIds": [ 809 | "4D1ojplACrlVIaNZ7P0FH" 810 | ], 811 | "roundness": { 812 | "type": 2 813 | }, 814 | "boundElements": [], 815 | "updated": 1678628196712, 816 | "link": null, 817 | "locked": false, 818 | "startBinding": null, 819 | "endBinding": null, 820 | "lastCommittedPoint": null, 821 | "startArrowhead": null, 822 | "endArrowhead": null, 823 | "points": [ 824 | [ 825 | 0, 826 | 0 827 | ], 828 | [ 829 | -17.21380615234375, 830 | 33.91400146484375 831 | ] 832 | ] 833 | }, 834 | { 835 | "type": "line", 836 | "version": 155, 837 | "versionNonce": 709164710, 838 | "isDeleted": false, 839 | "id": "hMliKg9HCYu-lEplUg1Y6", 840 | "fillStyle": "hachure", 841 | "strokeWidth": 2, 842 | "strokeStyle": "solid", 843 | "roughness": 1, 844 | "opacity": 100, 845 | "angle": 0, 846 | "x": 116.9343283795871, 847 | "y": 434.25992563182797, 848 | "strokeColor": "#000000", 849 | "backgroundColor": "transparent", 850 | "width": 12.9422607421875, 851 | "height": 35.16510009765625, 852 | "seed": 1969812986, 853 | "groupIds": [ 854 | "4D1ojplACrlVIaNZ7P0FH" 855 | ], 856 | "roundness": { 857 | "type": 2 858 | }, 859 | "boundElements": [], 860 | "updated": 1678628196712, 861 | "link": null, 862 | "locked": false, 863 | "startBinding": null, 864 | "endBinding": null, 865 | "lastCommittedPoint": null, 866 | "startArrowhead": null, 867 | "endArrowhead": null, 868 | "points": [ 869 | [ 870 | 0, 871 | 0 872 | ], 873 | [ 874 | 12.9422607421875, 875 | 35.16510009765625 876 | ] 877 | ] 878 | }, 879 | { 880 | "type": "line", 881 | "version": 171, 882 | "versionNonce": 1673148902, 883 | "isDeleted": false, 884 | "id": "pnA0DA25tJxDKgaSQnlkY", 885 | "fillStyle": "hachure", 886 | "strokeWidth": 2, 887 | "strokeStyle": "solid", 888 | "roughness": 1, 889 | "opacity": 100, 890 | "angle": 0, 891 | "x": 118.13015967841523, 892 | "y": 409.8340833466717, 893 | "strokeColor": "#000000", 894 | "backgroundColor": "transparent", 895 | "width": 29.445220947265625, 896 | "height": 20.990234375, 897 | "seed": 1200367290, 898 | "groupIds": [ 899 | "4D1ojplACrlVIaNZ7P0FH" 900 | ], 901 | "roundness": { 902 | "type": 2 903 | }, 904 | "boundElements": [], 905 | "updated": 1678628196712, 906 | "link": null, 907 | "locked": false, 908 | "startBinding": null, 909 | "endBinding": null, 910 | "lastCommittedPoint": null, 911 | "startArrowhead": null, 912 | "endArrowhead": null, 913 | "points": [ 914 | [ 915 | 0, 916 | 0 917 | ], 918 | [ 919 | 29.445220947265625, 920 | -20.990234375 921 | ] 922 | ] 923 | }, 924 | { 925 | "type": "line", 926 | "version": 210, 927 | "versionNonce": 157682982, 928 | "isDeleted": false, 929 | "id": "HhvpXqS4JXS1iu_1aiVep", 930 | "fillStyle": "hachure", 931 | "strokeWidth": 2, 932 | "strokeStyle": "solid", 933 | "roughness": 1, 934 | "opacity": 100, 935 | "angle": 0, 936 | "x": 117.2392600202121, 937 | "y": 409.10391977245297, 938 | "strokeColor": "#000000", 939 | "backgroundColor": "transparent", 940 | "width": 25.4169921875, 941 | "height": 9.85821533203125, 942 | "seed": 2005614458, 943 | "groupIds": [ 944 | "4D1ojplACrlVIaNZ7P0FH" 945 | ], 946 | "roundness": { 947 | "type": 2 948 | }, 949 | "boundElements": [], 950 | "updated": 1678628196712, 951 | "link": null, 952 | "locked": false, 953 | "startBinding": null, 954 | "endBinding": null, 955 | "lastCommittedPoint": null, 956 | "startArrowhead": null, 957 | "endArrowhead": null, 958 | "points": [ 959 | [ 960 | 0, 961 | 0 962 | ], 963 | [ 964 | -25.4169921875, 965 | -9.85821533203125 966 | ] 967 | ] 968 | }, 969 | { 970 | "type": "text", 971 | "version": 236, 972 | "versionNonce": 146110566, 973 | "isDeleted": false, 974 | "id": "T0m-48lm7wA_Uw99BXSmk", 975 | "fillStyle": "hachure", 976 | "strokeWidth": 2, 977 | "strokeStyle": "solid", 978 | "roughness": 1, 979 | "opacity": 100, 980 | "angle": 0, 981 | "x": 95.03036719794648, 982 | "y": 332.39002206737484, 983 | "strokeColor": "#000000", 984 | "backgroundColor": "transparent", 985 | "width": 44.679962158203125, 986 | "height": 24, 987 | "seed": 833175610, 988 | "groupIds": [ 989 | "4D1ojplACrlVIaNZ7P0FH" 990 | ], 991 | "roundness": null, 992 | "boundElements": [], 993 | "updated": 1678628196712, 994 | "link": null, 995 | "locked": false, 996 | "fontSize": 20, 997 | "fontFamily": 1, 998 | "text": "User", 999 | "textAlign": "left", 1000 | "verticalAlign": "top", 1001 | "containerId": null, 1002 | "originalText": "User", 1003 | "lineHeight": 1.2, 1004 | "baseline": 17 1005 | }, 1006 | { 1007 | "type": "arrow", 1008 | "version": 78, 1009 | "versionNonce": 1537206977, 1010 | "isDeleted": false, 1011 | "id": "yBKKo2h4VEy5ZKYYClSMJ", 1012 | "fillStyle": "hachure", 1013 | "strokeWidth": 1, 1014 | "strokeStyle": "solid", 1015 | "roughness": 1, 1016 | "opacity": 100, 1017 | "angle": 0, 1018 | "x": 693.1917746686496, 1019 | "y": 178.5963842610825, 1020 | "strokeColor": "#000000", 1021 | "backgroundColor": "transparent", 1022 | "width": 189.91119384765614, 1023 | "height": 0.27441060233906, 1024 | "seed": 230152570, 1025 | "groupIds": [], 1026 | "roundness": { 1027 | "type": 2 1028 | }, 1029 | "boundElements": [], 1030 | "updated": 1682156694780, 1031 | "link": null, 1032 | "locked": false, 1033 | "startBinding": { 1034 | "elementId": "LF8GW9IPL_GFv-IztvS9e", 1035 | "focus": -0.1026505713032812, 1036 | "gap": 2.240712004990087 1037 | }, 1038 | "endBinding": { 1039 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 1040 | "focus": 0.07612480547428331, 1041 | "gap": 3.9868949079075264 1042 | }, 1043 | "lastCommittedPoint": null, 1044 | "startArrowhead": "arrow", 1045 | "endArrowhead": "arrow", 1046 | "points": [ 1047 | [ 1048 | 0, 1049 | 0 1050 | ], 1051 | [ 1052 | -189.91119384765614, 1053 | 0.27441060233906 1054 | ] 1055 | ] 1056 | }, 1057 | { 1058 | "type": "arrow", 1059 | "version": 666, 1060 | "versionNonce": 116775610, 1061 | "isDeleted": false, 1062 | "id": "aoE5utJJiRd6nbLGf5SZL", 1063 | "fillStyle": "hachure", 1064 | "strokeWidth": 1, 1065 | "strokeStyle": "solid", 1066 | "roughness": 1, 1067 | "opacity": 100, 1068 | "angle": 0, 1069 | "x": 825.2347177489634, 1070 | "y": 239.6303785126874, 1071 | "strokeColor": "#087f5b", 1072 | "backgroundColor": "transparent", 1073 | "width": 127.38748653734501, 1074 | "height": 195.18328085992817, 1075 | "seed": 1867753638, 1076 | "groupIds": [], 1077 | "roundness": { 1078 | "type": 2 1079 | }, 1080 | "boundElements": [ 1081 | { 1082 | "type": "text", 1083 | "id": "er-wuNtPGiOMDs8nc5ZyS" 1084 | } 1085 | ], 1086 | "updated": 1678628174470, 1087 | "link": null, 1088 | "locked": false, 1089 | "startBinding": { 1090 | "elementId": "LF8GW9IPL_GFv-IztvS9e", 1091 | "focus": -0.35591087107126496, 1092 | "gap": 16.484144726405134 1093 | }, 1094 | "endBinding": { 1095 | "elementId": "-fBgLVr8TGW8aaNMK731H", 1096 | "focus": 0.7487886920580455, 1097 | "gap": 13.202774047851562 1098 | }, 1099 | "lastCommittedPoint": null, 1100 | "startArrowhead": null, 1101 | "endArrowhead": "arrow", 1102 | "points": [ 1103 | [ 1104 | 0, 1105 | 0 1106 | ], 1107 | [ 1108 | -25.36504341343141, 1109 | 102.56418215773232 1110 | ], 1111 | [ 1112 | -127.38748653734501, 1113 | 195.18328085992817 1114 | ] 1115 | ] 1116 | }, 1117 | { 1118 | "type": "text", 1119 | "version": 107, 1120 | "versionNonce": 1517231226, 1121 | "isDeleted": false, 1122 | "id": "er-wuNtPGiOMDs8nc5ZyS", 1123 | "fillStyle": "hachure", 1124 | "strokeWidth": 1, 1125 | "strokeStyle": "solid", 1126 | "roughness": 0, 1127 | "opacity": 100, 1128 | "angle": 0, 1129 | "x": 739.9997249947116, 1130 | "y": 306.1945606704197, 1131 | "strokeColor": "#087f5b", 1132 | "backgroundColor": "transparent", 1133 | "width": 119.73989868164062, 1134 | "height": 72, 1135 | "seed": 655657533, 1136 | "groupIds": [], 1137 | "roundness": null, 1138 | "boundElements": [], 1139 | "updated": 1678628142406, 1140 | "link": null, 1141 | "locked": false, 1142 | "fontSize": 20, 1143 | "fontFamily": 1, 1144 | "text": "5. API\nreturns\nmovies data", 1145 | "textAlign": "center", 1146 | "verticalAlign": "middle", 1147 | "containerId": "aoE5utJJiRd6nbLGf5SZL", 1148 | "originalText": "5. API\nreturns\nmovies data", 1149 | "lineHeight": 1.2, 1150 | "baseline": 65 1151 | }, 1152 | { 1153 | "type": "arrow", 1154 | "version": 355, 1155 | "versionNonce": 1434082342, 1156 | "isDeleted": false, 1157 | "id": "DsRU6l6oqgauSpfJP0FFX", 1158 | "fillStyle": "hachure", 1159 | "strokeWidth": 1, 1160 | "strokeStyle": "solid", 1161 | "roughness": 1, 1162 | "opacity": 100, 1163 | "angle": 0, 1164 | "x": 614.5418776668172, 1165 | "y": 381.04959848339047, 1166 | "strokeColor": "#087f5b", 1167 | "backgroundColor": "transparent", 1168 | "width": 110.00385612961156, 1169 | "height": 149.15231323242188, 1170 | "seed": 627287014, 1171 | "groupIds": [], 1172 | "roundness": { 1173 | "type": 2 1174 | }, 1175 | "boundElements": [ 1176 | { 1177 | "type": "text", 1178 | "id": "ZM6WcWNGOoqBbvNtUIvgC" 1179 | } 1180 | ], 1181 | "updated": 1678628174469, 1182 | "link": null, 1183 | "locked": false, 1184 | "startBinding": { 1185 | "elementId": "-fBgLVr8TGW8aaNMK731H", 1186 | "focus": 0.210816418591469, 1187 | "gap": 2.45135498046875 1188 | }, 1189 | "endBinding": { 1190 | "elementId": "LF8GW9IPL_GFv-IztvS9e", 1191 | "focus": -0.0737421766454546, 1192 | "gap": 8.751051464686384 1193 | }, 1194 | "lastCommittedPoint": null, 1195 | "startArrowhead": null, 1196 | "endArrowhead": "arrow", 1197 | "points": [ 1198 | [ 1199 | 0, 1200 | 0 1201 | ], 1202 | [ 1203 | 17.444574736207414, 1204 | -88.05746459960938 1205 | ], 1206 | [ 1207 | 110.00385612961156, 1208 | -149.15231323242188 1209 | ] 1210 | ] 1211 | }, 1212 | { 1213 | "type": "text", 1214 | "version": 69, 1215 | "versionNonce": 638156090, 1216 | "isDeleted": false, 1217 | "id": "ZM6WcWNGOoqBbvNtUIvgC", 1218 | "fillStyle": "hachure", 1219 | "strokeWidth": 1, 1220 | "strokeStyle": "solid", 1221 | "roughness": 0, 1222 | "opacity": 100, 1223 | "angle": 0, 1224 | "x": 565.4665091657199, 1225 | "y": 256.9921338837811, 1226 | "strokeColor": "#087f5b", 1227 | "backgroundColor": "transparent", 1228 | "width": 133.03988647460938, 1229 | "height": 72, 1230 | "seed": 419397459, 1231 | "groupIds": [], 1232 | "roundness": null, 1233 | "boundElements": [], 1234 | "updated": 1678628142407, 1235 | "link": null, 1236 | "locked": false, 1237 | "fontSize": 20, 1238 | "fontFamily": 1, 1239 | "text": "4. UI calls\nAPI using\nAccess Token", 1240 | "textAlign": "center", 1241 | "verticalAlign": "middle", 1242 | "containerId": "DsRU6l6oqgauSpfJP0FFX", 1243 | "originalText": "4. UI calls\nAPI using\nAccess Token", 1244 | "lineHeight": 1.2, 1245 | "baseline": 65 1246 | }, 1247 | { 1248 | "type": "arrow", 1249 | "version": 300, 1250 | "versionNonce": 453337402, 1251 | "isDeleted": false, 1252 | "id": "qEWWPxiXVvqSeK9IF7lW6", 1253 | "fillStyle": "hachure", 1254 | "strokeWidth": 1, 1255 | "strokeStyle": "solid", 1256 | "roughness": 1, 1257 | "opacity": 100, 1258 | "angle": 0, 1259 | "x": 490.3077430091188, 1260 | "y": 374.92469003612484, 1261 | "strokeColor": "#087f5b", 1262 | "backgroundColor": "transparent", 1263 | "width": 106.81383436316145, 1264 | "height": 138.295166015625, 1265 | "seed": 1716664102, 1266 | "groupIds": [], 1267 | "roundness": { 1268 | "type": 2 1269 | }, 1270 | "boundElements": [ 1271 | { 1272 | "type": "text", 1273 | "id": "gjz_FAYKD2OP56sU_7xj4" 1274 | } 1275 | ], 1276 | "updated": 1678628174469, 1277 | "link": null, 1278 | "locked": false, 1279 | "startBinding": { 1280 | "elementId": "-fBgLVr8TGW8aaNMK731H", 1281 | "focus": 0.035876397293634676, 1282 | "gap": 8.576263427734375 1283 | }, 1284 | "endBinding": { 1285 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 1286 | "focus": 0.20294364446735183, 1287 | "gap": 11.571693209952969 1288 | }, 1289 | "lastCommittedPoint": null, 1290 | "startArrowhead": null, 1291 | "endArrowhead": "arrow", 1292 | "points": [ 1293 | [ 1294 | 0, 1295 | 0 1296 | ], 1297 | [ 1298 | -90.50885164125043, 1299 | -54.8138427734375 1300 | ], 1301 | [ 1302 | -106.81383436316145, 1303 | -138.295166015625 1304 | ] 1305 | ] 1306 | }, 1307 | { 1308 | "type": "text", 1309 | "version": 186, 1310 | "versionNonce": 2031732218, 1311 | "isDeleted": false, 1312 | "id": "gjz_FAYKD2OP56sU_7xj4", 1313 | "fillStyle": "hachure", 1314 | "strokeWidth": 1, 1315 | "strokeStyle": "solid", 1316 | "roughness": 0, 1317 | "opacity": 100, 1318 | "angle": 0, 1319 | "x": 345.1889441632785, 1320 | "y": 296.11084726268734, 1321 | "strokeColor": "#087f5b", 1322 | "backgroundColor": "transparent", 1323 | "width": 109.21989440917969, 1324 | "height": 48, 1325 | "seed": 1602557725, 1326 | "groupIds": [], 1327 | "roundness": null, 1328 | "boundElements": [], 1329 | "updated": 1678628142407, 1330 | "link": null, 1331 | "locked": false, 1332 | "fontSize": 20, 1333 | "fontFamily": 1, 1334 | "text": "2. Check\nCredentials", 1335 | "textAlign": "center", 1336 | "verticalAlign": "middle", 1337 | "containerId": "qEWWPxiXVvqSeK9IF7lW6", 1338 | "originalText": "2. Check\nCredentials", 1339 | "lineHeight": 1.2, 1340 | "baseline": 41 1341 | }, 1342 | { 1343 | "type": "arrow", 1344 | "version": 483, 1345 | "versionNonce": 191358822, 1346 | "isDeleted": false, 1347 | "id": "Sp9Xg4WQENiW_MemWMvHF", 1348 | "fillStyle": "hachure", 1349 | "strokeWidth": 1, 1350 | "strokeStyle": "solid", 1351 | "roughness": 1, 1352 | "opacity": 100, 1353 | "angle": 0, 1354 | "x": 430.7782314147611, 1355 | "y": 237.0236890595624, 1356 | "strokeColor": "#087f5b", 1357 | "backgroundColor": "transparent", 1358 | "width": 106.63302792344734, 1359 | "height": 136.06457519531244, 1360 | "seed": 176972390, 1361 | "groupIds": [], 1362 | "roundness": { 1363 | "type": 2 1364 | }, 1365 | "boundElements": [ 1366 | { 1367 | "type": "text", 1368 | "id": "y2AoPto0TIYmHi6dbKxGP" 1369 | } 1370 | ], 1371 | "updated": 1678628174470, 1372 | "link": null, 1373 | "locked": false, 1374 | "startBinding": { 1375 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 1376 | "focus": 0.4049537414412572, 1377 | "gap": 11.965858249015469 1378 | }, 1379 | "endBinding": { 1380 | "elementId": "-fBgLVr8TGW8aaNMK731H", 1381 | "focus": -0.2041023099878151, 1382 | "gap": 10.412689208984375 1383 | }, 1384 | "lastCommittedPoint": null, 1385 | "startArrowhead": null, 1386 | "endArrowhead": "arrow", 1387 | "points": [ 1388 | [ 1389 | 0, 1390 | 0 1391 | ], 1392 | [ 1393 | 78.00601151560727, 1394 | 41.39459228515619 1395 | ], 1396 | [ 1397 | 106.63302792344734, 1398 | 136.06457519531244 1399 | ] 1400 | ] 1401 | }, 1402 | { 1403 | "type": "text", 1404 | "version": 198, 1405 | "versionNonce": 1017312954, 1406 | "isDeleted": false, 1407 | "id": "y2AoPto0TIYmHi6dbKxGP", 1408 | "fillStyle": "hachure", 1409 | "strokeWidth": 1, 1410 | "strokeStyle": "solid", 1411 | "roughness": 0, 1412 | "opacity": 100, 1413 | "angle": 0, 1414 | "x": 480.84425574775116, 1415 | "y": 266.4182813447186, 1416 | "strokeColor": "#087f5b", 1417 | "backgroundColor": "transparent", 1418 | "width": 55.879974365234375, 1419 | "height": 24, 1420 | "seed": 726335293, 1421 | "groupIds": [], 1422 | "roundness": null, 1423 | "boundElements": [], 1424 | "updated": 1678628142407, 1425 | "link": null, 1426 | "locked": false, 1427 | "fontSize": 20, 1428 | "fontFamily": 1, 1429 | "text": "3. OK", 1430 | "textAlign": "center", 1431 | "verticalAlign": "middle", 1432 | "containerId": "Sp9Xg4WQENiW_MemWMvHF", 1433 | "originalText": "3. OK", 1434 | "lineHeight": 1.2, 1435 | "baseline": 17 1436 | }, 1437 | { 1438 | "type": "ellipse", 1439 | "version": 342, 1440 | "versionNonce": 1961697210, 1441 | "isDeleted": false, 1442 | "id": "s6b0o3EXLE89MvqfGkUns", 1443 | "fillStyle": "hachure", 1444 | "strokeWidth": 2, 1445 | "strokeStyle": "solid", 1446 | "roughness": 1, 1447 | "opacity": 100, 1448 | "angle": 0, 1449 | "x": 580.0081504010715, 1450 | "y": -77.9960252959064, 1451 | "strokeColor": "#000000", 1452 | "backgroundColor": "transparent", 1453 | "width": 26.930389404296875, 1454 | "height": 27.545562744140625, 1455 | "seed": 2065888678, 1456 | "groupIds": [ 1457 | "u7g1JzQZ0WL6fcn3IY7co" 1458 | ], 1459 | "roundness": { 1460 | "type": 2 1461 | }, 1462 | "boundElements": [], 1463 | "updated": 1678628202108, 1464 | "link": null, 1465 | "locked": false 1466 | }, 1467 | { 1468 | "type": "line", 1469 | "version": 363, 1470 | "versionNonce": 543496314, 1471 | "isDeleted": false, 1472 | "id": "2uTa355SnJV7AGa7mH2Yi", 1473 | "fillStyle": "hachure", 1474 | "strokeWidth": 2, 1475 | "strokeStyle": "solid", 1476 | "roughness": 1, 1477 | "opacity": 100, 1478 | "angle": 0, 1479 | "x": 591.4459860944309, 1480 | "y": -49.99538442676578, 1481 | "strokeColor": "#000000", 1482 | "backgroundColor": "transparent", 1483 | "width": 0.473419189453125, 1484 | "height": 40.3687744140625, 1485 | "seed": 1942048998, 1486 | "groupIds": [ 1487 | "u7g1JzQZ0WL6fcn3IY7co" 1488 | ], 1489 | "roundness": { 1490 | "type": 2 1491 | }, 1492 | "boundElements": [], 1493 | "updated": 1678628202108, 1494 | "link": null, 1495 | "locked": false, 1496 | "startBinding": null, 1497 | "endBinding": null, 1498 | "lastCommittedPoint": null, 1499 | "startArrowhead": null, 1500 | "endArrowhead": null, 1501 | "points": [ 1502 | [ 1503 | 0, 1504 | 0 1505 | ], 1506 | [ 1507 | -0.473419189453125, 1508 | 40.3687744140625 1509 | ] 1510 | ] 1511 | }, 1512 | { 1513 | "type": "line", 1514 | "version": 314, 1515 | "versionNonce": 870316346, 1516 | "isDeleted": false, 1517 | "id": "oM6ILnVKaA2gGkNcBhjZN", 1518 | "fillStyle": "hachure", 1519 | "strokeWidth": 2, 1520 | "strokeStyle": "solid", 1521 | "roughness": 1, 1522 | "opacity": 100, 1523 | "angle": 0, 1524 | "x": 591.1504843854465, 1525 | "y": -8.191734524422031, 1526 | "strokeColor": "#000000", 1527 | "backgroundColor": "transparent", 1528 | "width": 17.21380615234375, 1529 | "height": 33.91400146484375, 1530 | "seed": 1450329126, 1531 | "groupIds": [ 1532 | "u7g1JzQZ0WL6fcn3IY7co" 1533 | ], 1534 | "roundness": { 1535 | "type": 2 1536 | }, 1537 | "boundElements": [], 1538 | "updated": 1678628202108, 1539 | "link": null, 1540 | "locked": false, 1541 | "startBinding": null, 1542 | "endBinding": null, 1543 | "lastCommittedPoint": null, 1544 | "startArrowhead": null, 1545 | "endArrowhead": null, 1546 | "points": [ 1547 | [ 1548 | 0, 1549 | 0 1550 | ], 1551 | [ 1552 | -17.21380615234375, 1553 | 33.91400146484375 1554 | ] 1555 | ] 1556 | }, 1557 | { 1558 | "type": "line", 1559 | "version": 333, 1560 | "versionNonce": 2126561786, 1561 | "isDeleted": false, 1562 | "id": "k6SVayfoANqiSMRE9yF5M", 1563 | "fillStyle": "hachure", 1564 | "strokeWidth": 2, 1565 | "strokeStyle": "solid", 1566 | "roughness": 1, 1567 | "opacity": 100, 1568 | "angle": 0, 1569 | "x": 591.266329112009, 1570 | "y": -8.071556301765781, 1571 | "strokeColor": "#000000", 1572 | "backgroundColor": "transparent", 1573 | "width": 12.9422607421875, 1574 | "height": 35.16510009765625, 1575 | "seed": 1965215590, 1576 | "groupIds": [ 1577 | "u7g1JzQZ0WL6fcn3IY7co" 1578 | ], 1579 | "roundness": { 1580 | "type": 2 1581 | }, 1582 | "boundElements": [], 1583 | "updated": 1678628202108, 1584 | "link": null, 1585 | "locked": false, 1586 | "startBinding": null, 1587 | "endBinding": null, 1588 | "lastCommittedPoint": null, 1589 | "startArrowhead": null, 1590 | "endArrowhead": null, 1591 | "points": [ 1592 | [ 1593 | 0, 1594 | 0 1595 | ], 1596 | [ 1597 | 12.9422607421875, 1598 | 35.16510009765625 1599 | ] 1600 | ] 1601 | }, 1602 | { 1603 | "type": "line", 1604 | "version": 349, 1605 | "versionNonce": 1812624058, 1606 | "isDeleted": false, 1607 | "id": "vR2qNnN5GZahQ6PZgXKLz", 1608 | "fillStyle": "hachure", 1609 | "strokeWidth": 2, 1610 | "strokeStyle": "solid", 1611 | "roughness": 1, 1612 | "opacity": 100, 1613 | "angle": 0, 1614 | "x": 592.4621604108371, 1615 | "y": -32.49739858692203, 1616 | "strokeColor": "#000000", 1617 | "backgroundColor": "transparent", 1618 | "width": 29.445220947265625, 1619 | "height": 20.990234375, 1620 | "seed": 1545597606, 1621 | "groupIds": [ 1622 | "u7g1JzQZ0WL6fcn3IY7co" 1623 | ], 1624 | "roundness": { 1625 | "type": 2 1626 | }, 1627 | "boundElements": [], 1628 | "updated": 1678628202108, 1629 | "link": null, 1630 | "locked": false, 1631 | "startBinding": null, 1632 | "endBinding": null, 1633 | "lastCommittedPoint": null, 1634 | "startArrowhead": null, 1635 | "endArrowhead": null, 1636 | "points": [ 1637 | [ 1638 | 0, 1639 | 0 1640 | ], 1641 | [ 1642 | 29.445220947265625, 1643 | -20.990234375 1644 | ] 1645 | ] 1646 | }, 1647 | { 1648 | "type": "line", 1649 | "version": 388, 1650 | "versionNonce": 241175418, 1651 | "isDeleted": false, 1652 | "id": "uwlaBbyWa_J2CP7wHadaY", 1653 | "fillStyle": "hachure", 1654 | "strokeWidth": 2, 1655 | "strokeStyle": "solid", 1656 | "roughness": 1, 1657 | "opacity": 100, 1658 | "angle": 0, 1659 | "x": 591.571260752634, 1660 | "y": -33.22756216114078, 1661 | "strokeColor": "#000000", 1662 | "backgroundColor": "transparent", 1663 | "width": 25.4169921875, 1664 | "height": 9.85821533203125, 1665 | "seed": 102149606, 1666 | "groupIds": [ 1667 | "u7g1JzQZ0WL6fcn3IY7co" 1668 | ], 1669 | "roundness": { 1670 | "type": 2 1671 | }, 1672 | "boundElements": [], 1673 | "updated": 1678628202108, 1674 | "link": null, 1675 | "locked": false, 1676 | "startBinding": null, 1677 | "endBinding": null, 1678 | "lastCommittedPoint": null, 1679 | "startArrowhead": null, 1680 | "endArrowhead": null, 1681 | "points": [ 1682 | [ 1683 | 0, 1684 | 0 1685 | ], 1686 | [ 1687 | -25.4169921875, 1688 | -9.85821533203125 1689 | ] 1690 | ] 1691 | }, 1692 | { 1693 | "type": "text", 1694 | "version": 323, 1695 | "versionNonce": 738495546, 1696 | "isDeleted": false, 1697 | "id": "GRFVRUsIgnm6E4hNYNpue", 1698 | "fillStyle": "hachure", 1699 | "strokeWidth": 2, 1700 | "strokeStyle": "solid", 1701 | "roughness": 1, 1702 | "opacity": 100, 1703 | "angle": 0, 1704 | "x": 569.0842612409152, 1705 | "y": -107.4155809599689, 1706 | "strokeColor": "#000000", 1707 | "backgroundColor": "transparent", 1708 | "width": 50.87995910644531, 1709 | "height": 24, 1710 | "seed": 269746470, 1711 | "groupIds": [ 1712 | "u7g1JzQZ0WL6fcn3IY7co" 1713 | ], 1714 | "roundness": null, 1715 | "boundElements": [], 1716 | "updated": 1678628202108, 1717 | "link": null, 1718 | "locked": false, 1719 | "fontSize": 20, 1720 | "fontFamily": 1, 1721 | "text": "Admin", 1722 | "textAlign": "left", 1723 | "verticalAlign": "top", 1724 | "containerId": null, 1725 | "originalText": "Admin", 1726 | "lineHeight": 1.2, 1727 | "baseline": 17 1728 | }, 1729 | { 1730 | "type": "arrow", 1731 | "version": 157, 1732 | "versionNonce": 754526714, 1733 | "isDeleted": false, 1734 | "id": "rrKSZflKpeluTvYOF6GrZ", 1735 | "fillStyle": "hachure", 1736 | "strokeWidth": 1, 1737 | "strokeStyle": "solid", 1738 | "roughness": 1, 1739 | "opacity": 100, 1740 | "angle": 0, 1741 | "x": 257.57977515693085, 1742 | "y": 404.2298352997967, 1743 | "strokeColor": "#087f5b", 1744 | "backgroundColor": "transparent", 1745 | "width": 208.11212158203125, 1746 | "height": 0.8022733868234582, 1747 | "seed": 1423910310, 1748 | "groupIds": [], 1749 | "roundness": { 1750 | "type": 2 1751 | }, 1752 | "boundElements": [ 1753 | { 1754 | "type": "text", 1755 | "id": "oPWTS5mv61qVmDQRTH7Gg" 1756 | } 1757 | ], 1758 | "updated": 1678628174470, 1759 | "link": null, 1760 | "locked": false, 1761 | "startBinding": null, 1762 | "endBinding": { 1763 | "elementId": "-fBgLVr8TGW8aaNMK731H", 1764 | "focus": 0.5546206190054114, 1765 | "gap": 9.768997192382812 1766 | }, 1767 | "lastCommittedPoint": null, 1768 | "startArrowhead": null, 1769 | "endArrowhead": "arrow", 1770 | "points": [ 1771 | [ 1772 | 0, 1773 | 0 1774 | ], 1775 | [ 1776 | 208.11212158203125, 1777 | 0.8022733868234582 1778 | ] 1779 | ] 1780 | }, 1781 | { 1782 | "type": "text", 1783 | "version": 38, 1784 | "versionNonce": 1255863546, 1785 | "isDeleted": false, 1786 | "id": "oPWTS5mv61qVmDQRTH7Gg", 1787 | "fillStyle": "hachure", 1788 | "strokeWidth": 1, 1789 | "strokeStyle": "solid", 1790 | "roughness": 0, 1791 | "opacity": 100, 1792 | "angle": 0, 1793 | "x": 307.6158774518527, 1794 | "y": 368.63097199320845, 1795 | "strokeColor": "#087f5b", 1796 | "backgroundColor": "transparent", 1797 | "width": 108.0399169921875, 1798 | "height": 72, 1799 | "seed": 1681629459, 1800 | "groupIds": [], 1801 | "roundness": null, 1802 | "boundElements": [], 1803 | "updated": 1678628293632, 1804 | "link": null, 1805 | "locked": false, 1806 | "fontSize": 20, 1807 | "fontFamily": 1, 1808 | "text": "1.\nusername /\npassword", 1809 | "textAlign": "center", 1810 | "verticalAlign": "middle", 1811 | "containerId": "rrKSZflKpeluTvYOF6GrZ", 1812 | "originalText": "1.\nusername /\npassword", 1813 | "lineHeight": 1.2, 1814 | "baseline": 65 1815 | }, 1816 | { 1817 | "type": "arrow", 1818 | "version": 248, 1819 | "versionNonce": 436341414, 1820 | "isDeleted": false, 1821 | "id": "u_uFh-baXw7JZVs8vLiR_", 1822 | "fillStyle": "hachure", 1823 | "strokeWidth": 1, 1824 | "strokeStyle": "solid", 1825 | "roughness": 1, 1826 | "opacity": 100, 1827 | "angle": 0, 1828 | "x": 458.31390601630585, 1829 | "y": 481.6638851221986, 1830 | "strokeColor": "#087f5b", 1831 | "backgroundColor": "transparent", 1832 | "width": 202.29611206054688, 1833 | "height": 1.132261492323778, 1834 | "seed": 645365990, 1835 | "groupIds": [], 1836 | "roundness": { 1837 | "type": 2 1838 | }, 1839 | "boundElements": [ 1840 | { 1841 | "type": "text", 1842 | "id": "9Wg48hitSUp9OaAaqNEfl" 1843 | } 1844 | ], 1845 | "updated": 1678628174470, 1846 | "link": null, 1847 | "locked": false, 1848 | "startBinding": { 1849 | "elementId": "-fBgLVr8TGW8aaNMK731H", 1850 | "focus": -0.9719994098769414, 1851 | "gap": 17.146987915039062 1852 | }, 1853 | "endBinding": null, 1854 | "lastCommittedPoint": null, 1855 | "startArrowhead": null, 1856 | "endArrowhead": "arrow", 1857 | "points": [ 1858 | [ 1859 | 0, 1860 | 0 1861 | ], 1862 | [ 1863 | -202.29611206054688, 1864 | -1.132261492323778 1865 | ] 1866 | ] 1867 | }, 1868 | { 1869 | "type": "text", 1870 | "version": 116, 1871 | "versionNonce": 473329574, 1872 | "isDeleted": false, 1873 | "id": "9Wg48hitSUp9OaAaqNEfl", 1874 | "fillStyle": "hachure", 1875 | "strokeWidth": 1, 1876 | "strokeStyle": "solid", 1877 | "roughness": 0, 1878 | "opacity": 100, 1879 | "angle": 0, 1880 | "x": 326.9758856915988, 1881 | "y": 445.09775437603673, 1882 | "strokeColor": "#087f5b", 1883 | "backgroundColor": "transparent", 1884 | "width": 60.37992858886719, 1885 | "height": 72, 1886 | "seed": 524876477, 1887 | "groupIds": [], 1888 | "roundness": null, 1889 | "boundElements": [], 1890 | "updated": 1678628293632, 1891 | "link": null, 1892 | "locked": false, 1893 | "fontSize": 20, 1894 | "fontFamily": 1, 1895 | "text": "6.\nmovies\ndata", 1896 | "textAlign": "center", 1897 | "verticalAlign": "middle", 1898 | "containerId": "u_uFh-baXw7JZVs8vLiR_", 1899 | "originalText": "6.\nmovies\ndata", 1900 | "lineHeight": 1.2, 1901 | "baseline": 65 1902 | }, 1903 | { 1904 | "type": "arrow", 1905 | "version": 442, 1906 | "versionNonce": 751898022, 1907 | "isDeleted": false, 1908 | "id": "POtOtVS0wydJOmbpRxpmR", 1909 | "fillStyle": "hachure", 1910 | "strokeWidth": 1, 1911 | "strokeStyle": "solid", 1912 | "roughness": 1, 1913 | "opacity": 100, 1914 | "angle": 0, 1915 | "x": 544.3267139632569, 1916 | "y": -56.57274779686004, 1917 | "strokeColor": "#364fc7", 1918 | "backgroundColor": "transparent", 1919 | "width": 233.15837969528087, 1920 | "height": 169.15716683689118, 1921 | "seed": 1319416998, 1922 | "groupIds": [], 1923 | "roundness": { 1924 | "type": 2 1925 | }, 1926 | "boundElements": [ 1927 | { 1928 | "type": "text", 1929 | "id": "wARZFwrxiuSdbk-PkWBpw" 1930 | } 1931 | ], 1932 | "updated": 1678628174469, 1933 | "link": null, 1934 | "locked": false, 1935 | "startBinding": null, 1936 | "endBinding": { 1937 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 1938 | "focus": -0.8436669242333421, 1939 | "gap": 12.802696438484489 1940 | }, 1941 | "lastCommittedPoint": null, 1942 | "startArrowhead": null, 1943 | "endArrowhead": "arrow", 1944 | "points": [ 1945 | [ 1946 | 0, 1947 | 0 1948 | ], 1949 | [ 1950 | -199.63495803150568, 1951 | 22.490500148246184 1952 | ], 1953 | [ 1954 | -233.15837969528087, 1955 | 169.15716683689118 1956 | ] 1957 | ] 1958 | }, 1959 | { 1960 | "type": "text", 1961 | "version": 90, 1962 | "versionNonce": 1158198906, 1963 | "isDeleted": false, 1964 | "id": "wARZFwrxiuSdbk-PkWBpw", 1965 | "fillStyle": "hachure", 1966 | "strokeWidth": 1, 1967 | "strokeStyle": "solid", 1968 | "roughness": 0, 1969 | "opacity": 100, 1970 | "angle": 0, 1971 | "x": 236.59184138096998, 1972 | "y": -70.08224764861386, 1973 | "strokeColor": "#364fc7", 1974 | "backgroundColor": "transparent", 1975 | "width": 216.1998291015625, 1976 | "height": 72, 1977 | "seed": 1147669437, 1978 | "groupIds": [], 1979 | "roundness": null, 1980 | "boundElements": [], 1981 | "updated": 1678628130094, 1982 | "link": null, 1983 | "locked": false, 1984 | "fontSize": 20, 1985 | "fontFamily": 1, 1986 | "text": "1.\nusername /password /\nclientId", 1987 | "textAlign": "center", 1988 | "verticalAlign": "middle", 1989 | "containerId": "POtOtVS0wydJOmbpRxpmR", 1990 | "originalText": "1.\nusername /password / clientId", 1991 | "lineHeight": 1.2, 1992 | "baseline": 65 1993 | }, 1994 | { 1995 | "type": "arrow", 1996 | "version": 367, 1997 | "versionNonce": 1587736193, 1998 | "isDeleted": false, 1999 | "id": "nygJVkGwIGzzo_tBqH1MG", 2000 | "fillStyle": "hachure", 2001 | "strokeWidth": 1, 2002 | "strokeStyle": "solid", 2003 | "roughness": 0, 2004 | "opacity": 100, 2005 | "angle": 0, 2006 | "x": 409.4003106587122, 2007 | "y": 108.90601327831233, 2008 | "strokeColor": "#364fc7", 2009 | "backgroundColor": "transparent", 2010 | "width": 154.0783339057299, 2011 | "height": 89.125244140625, 2012 | "seed": 628142515, 2013 | "groupIds": [], 2014 | "roundness": { 2015 | "type": 2 2016 | }, 2017 | "boundElements": [ 2018 | { 2019 | "type": "text", 2020 | "id": "YXlqllHmPHsjF3nYlUiI8" 2021 | } 2022 | ], 2023 | "updated": 1682156695598, 2024 | "link": null, 2025 | "locked": false, 2026 | "startBinding": { 2027 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 2028 | "focus": 0.007712925539689392, 2029 | "gap": 16.481102200203296 2030 | }, 2031 | "endBinding": null, 2032 | "lastCommittedPoint": null, 2033 | "startArrowhead": null, 2034 | "endArrowhead": "arrow", 2035 | "points": [ 2036 | [ 2037 | 0, 2038 | 0 2039 | ], 2040 | [ 2041 | 15.835993077629023, 2042 | -76.03634928854163 2043 | ], 2044 | [ 2045 | 154.0783339057299, 2046 | -89.125244140625 2047 | ] 2048 | ] 2049 | }, 2050 | { 2051 | "type": "text", 2052 | "version": 103, 2053 | "versionNonce": 310070255, 2054 | "isDeleted": false, 2055 | "id": "YXlqllHmPHsjF3nYlUiI8", 2056 | "fillStyle": "hachure", 2057 | "strokeWidth": 1, 2058 | "strokeStyle": "solid", 2059 | "roughness": 0, 2060 | "opacity": 100, 2061 | "angle": 0, 2062 | "x": 358.71636049903657, 2063 | "y": -3.1303360102293, 2064 | "strokeColor": "#364fc7", 2065 | "backgroundColor": "transparent", 2066 | "width": 133.03988647460938, 2067 | "height": 72, 2068 | "seed": 807220445, 2069 | "groupIds": [], 2070 | "roundness": null, 2071 | "boundElements": [], 2072 | "updated": 1682156695556, 2073 | "link": null, 2074 | "locked": false, 2075 | "fontSize": 20, 2076 | "fontFamily": 1, 2077 | "text": "2.\nAccess Token\n(JWT)", 2078 | "textAlign": "center", 2079 | "verticalAlign": "middle", 2080 | "containerId": "nygJVkGwIGzzo_tBqH1MG", 2081 | "originalText": "2.\nAccess Token\n(JWT)", 2082 | "lineHeight": 1.2, 2083 | "baseline": 65 2084 | }, 2085 | { 2086 | "type": "arrow", 2087 | "version": 606, 2088 | "versionNonce": 754672166, 2089 | "isDeleted": false, 2090 | "id": "piKHBb0Cd9VEOt27_Txy7", 2091 | "fillStyle": "hachure", 2092 | "strokeWidth": 1, 2093 | "strokeStyle": "solid", 2094 | "roughness": 1, 2095 | "opacity": 100, 2096 | "angle": 0, 2097 | "x": 632.394977157343, 2098 | "y": -44.367653103523594, 2099 | "strokeColor": "#364fc7", 2100 | "backgroundColor": "transparent", 2101 | "width": 204.36346701594812, 2102 | "height": 153.85226440429688, 2103 | "seed": 246624230, 2104 | "groupIds": [], 2105 | "roundness": { 2106 | "type": 2 2107 | }, 2108 | "boundElements": [ 2109 | { 2110 | "type": "text", 2111 | "id": "8BOjsxvRptxEA8OboK1cq" 2112 | } 2113 | ], 2114 | "updated": 1678628174468, 2115 | "link": null, 2116 | "locked": false, 2117 | "startBinding": null, 2118 | "endBinding": { 2119 | "elementId": "LF8GW9IPL_GFv-IztvS9e", 2120 | "focus": 0.4149599714807454, 2121 | "gap": 13.990907153477679 2122 | }, 2123 | "lastCommittedPoint": null, 2124 | "startArrowhead": null, 2125 | "endArrowhead": "arrow", 2126 | "points": [ 2127 | [ 2128 | 0, 2129 | 0 2130 | ], 2131 | [ 2132 | 183.188676545847, 2133 | 16.82079005206026 2134 | ], 2135 | [ 2136 | 204.36346701594812, 2137 | 153.85226440429688 2138 | ] 2139 | ] 2140 | }, 2141 | { 2142 | "type": "text", 2143 | "version": 127, 2144 | "versionNonce": 2120210234, 2145 | "isDeleted": false, 2146 | "id": "8BOjsxvRptxEA8OboK1cq", 2147 | "fillStyle": "hachure", 2148 | "strokeWidth": 1, 2149 | "strokeStyle": "solid", 2150 | "roughness": 0, 2151 | "opacity": 100, 2152 | "angle": 0, 2153 | "x": 747.7237141279946, 2154 | "y": -87.54686305146333, 2155 | "strokeColor": "#364fc7", 2156 | "backgroundColor": "transparent", 2157 | "width": 135.71987915039062, 2158 | "height": 120, 2159 | "seed": 528714525, 2160 | "groupIds": [], 2161 | "roundness": null, 2162 | "boundElements": [], 2163 | "updated": 1678628130094, 2164 | "link": null, 2165 | "locked": false, 2166 | "fontSize": 20, 2167 | "fontFamily": 1, 2168 | "text": "3. uses \nAccess Token\nto call\nAPI endpoints\ndirectly", 2169 | "textAlign": "center", 2170 | "verticalAlign": "middle", 2171 | "containerId": "piKHBb0Cd9VEOt27_Txy7", 2172 | "originalText": "3. uses \nAccess Token\nto call\nAPI endpoints\ndirectly", 2173 | "lineHeight": 1.2, 2174 | "baseline": 113 2175 | }, 2176 | { 2177 | "id": "U8jVXWMsTfFs21OLya7-G", 2178 | "type": "arrow", 2179 | "x": 199.5664741003975, 2180 | "y": 174.46796803625608, 2181 | "width": 87.4124755859375, 2182 | "height": 0.54931640625, 2183 | "angle": 0, 2184 | "strokeColor": "#000000", 2185 | "backgroundColor": "transparent", 2186 | "fillStyle": "hachure", 2187 | "strokeWidth": 1, 2188 | "strokeStyle": "solid", 2189 | "roughness": 1, 2190 | "opacity": 100, 2191 | "groupIds": [], 2192 | "roundness": { 2193 | "type": 2 2194 | }, 2195 | "seed": 390626337, 2196 | "version": 80, 2197 | "versionNonce": 172783471, 2198 | "isDeleted": false, 2199 | "boundElements": null, 2200 | "updated": 1682156676694, 2201 | "link": null, 2202 | "locked": false, 2203 | "points": [ 2204 | [ 2205 | 0, 2206 | 0 2207 | ], 2208 | [ 2209 | 87.4124755859375, 2210 | 0.54931640625 2211 | ] 2212 | ], 2213 | "lastCommittedPoint": null, 2214 | "startBinding": { 2215 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 2216 | "focus": 0.041889748100537666, 2217 | "gap": 1.9213714599608238 2218 | }, 2219 | "endBinding": { 2220 | "elementId": "htH4DvpAlw_lK0WCfkn_y", 2221 | "focus": -0.009343196185747972, 2222 | "gap": 3.131172994329063 2223 | }, 2224 | "startArrowhead": null, 2225 | "endArrowhead": null 2226 | }, 2227 | { 2228 | "id": "kW2uU-UIG2e89viZqg-Yl", 2229 | "type": "arrow", 2230 | "x": 908.5372382605537, 2231 | "y": 175.88358693274046, 2232 | "width": 77.4005126953125, 2233 | "height": 2.1214599609375, 2234 | "angle": 0, 2235 | "strokeColor": "#000000", 2236 | "backgroundColor": "transparent", 2237 | "fillStyle": "hachure", 2238 | "strokeWidth": 1, 2239 | "strokeStyle": "solid", 2240 | "roughness": 1, 2241 | "opacity": 100, 2242 | "groupIds": [], 2243 | "roundness": { 2244 | "type": 2 2245 | }, 2246 | "seed": 806302433, 2247 | "version": 31, 2248 | "versionNonce": 1223504783, 2249 | "isDeleted": false, 2250 | "boundElements": null, 2251 | "updated": 1682156687011, 2252 | "link": null, 2253 | "locked": false, 2254 | "points": [ 2255 | [ 2256 | 0, 2257 | 0 2258 | ], 2259 | [ 2260 | 77.4005126953125, 2261 | -2.1214599609375 2262 | ] 2263 | ], 2264 | "lastCommittedPoint": null, 2265 | "startBinding": { 2266 | "elementId": "LF8GW9IPL_GFv-IztvS9e", 2267 | "focus": 0.10525060452061027, 2268 | "gap": 3.9211883544921875 2269 | }, 2270 | "endBinding": { 2271 | "elementId": "nPUKoIL6nC8L6dHS8GRdd", 2272 | "focus": 0.05876170975183986, 2273 | "gap": 3.3945770263671875 2274 | }, 2275 | "startArrowhead": null, 2276 | "endArrowhead": null 2277 | } 2278 | ], 2279 | "appState": { 2280 | "gridSize": null, 2281 | "viewBackgroundColor": "#ffffff" 2282 | }, 2283 | "files": {} 2284 | } -------------------------------------------------------------------------------- /documentation/project-diagram.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-react-keycloak/ff77df650683377b1684b398f5639342851beead/documentation/project-diagram.jpeg -------------------------------------------------------------------------------- /init-environment.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | MONGO_VERSION="8.0.5" 4 | POSTGRES_VERSION="17.2" 5 | KEYCLOAK_VERSION="26.1.3" 6 | 7 | source scripts/my-functions.sh 8 | 9 | echo 10 | echo "Starting environment" 11 | echo "====================" 12 | 13 | echo 14 | echo "Creating network" 15 | echo "----------------" 16 | docker network create springboot-react-keycloak-net 17 | 18 | echo 19 | echo "Starting mongodb" 20 | echo "----------------" 21 | 22 | docker run -d \ 23 | --name mongodb \ 24 | -p 27017:27017 \ 25 | --network=springboot-react-keycloak-net \ 26 | mongo:${MONGO_VERSION} 27 | 28 | echo 29 | echo "Starting postgres" 30 | echo "-----------------" 31 | 32 | docker run -d \ 33 | --name postgres \ 34 | -p 5432:5432 \ 35 | -e POSTGRES_DB=keycloak \ 36 | -e POSTGRES_USER=keycloak \ 37 | -e POSTGRES_PASSWORD=password \ 38 | --network=springboot-react-keycloak-net \ 39 | postgres:${POSTGRES_VERSION} 40 | 41 | echo 42 | echo "Starting keycloak" 43 | echo "-----------------" 44 | 45 | docker run -d \ 46 | --name keycloak \ 47 | -p 8080:8080 \ 48 | -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ 49 | -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ 50 | -e KC_DB=postgres \ 51 | -e KC_DB_URL_HOST=postgres \ 52 | -e KC_DB_URL_DATABASE=keycloak \ 53 | -e KC_DB_USERNAME=keycloak \ 54 | -e KC_DB_PASSWORD=password \ 55 | --network=springboot-react-keycloak-net \ 56 | quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} start-dev 57 | 58 | echo 59 | wait_for_container_log "mongodb" "Waiting for connections" 60 | 61 | echo 62 | wait_for_container_log "postgres" "database system is ready" 63 | 64 | echo 65 | wait_for_container_log "keycloak" "started in" 66 | 67 | echo 68 | echo "Environment Up and Running" 69 | echo "==========================" 70 | echo -------------------------------------------------------------------------------- /init-keycloak.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ -z $(docker ps --filter "name=keycloak" -q) ]]; then 4 | echo "[WARNING] You must initialize the envionment (./init-environment.sh) before initializing keycloak" 5 | exit 1 6 | fi 7 | 8 | KEYCLOAK_HOST_PORT=${1:-"localhost:8080"} 9 | echo 10 | echo "KEYCLOAK_HOST_PORT: $KEYCLOAK_HOST_PORT" 11 | 12 | echo 13 | echo "Getting admin access token" 14 | echo "--------------------------" 15 | 16 | ADMIN_TOKEN=$(curl -s -X POST "http://$KEYCLOAK_HOST_PORT/realms/master/protocol/openid-connect/token" \ 17 | -H "Content-Type: application/x-www-form-urlencoded" \ 18 | -d "username=admin" \ 19 | -d 'password=admin' \ 20 | -d 'grant_type=password' \ 21 | -d 'client_id=admin-cli' | jq -r '.access_token') 22 | 23 | echo "ADMIN_TOKEN=$ADMIN_TOKEN" 24 | echo 25 | 26 | echo "Creating company-services realm" 27 | echo "-------------------------------" 28 | 29 | curl -i -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms" \ 30 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 31 | -H "Content-Type: application/json" \ 32 | -d '{"realm": "company-services", "enabled": true, "registrationAllowed": true}' 33 | 34 | echo "Getting required action Verify Profile" 35 | echo "--------------------------------------" 36 | 37 | VERIFY_PROFILE_REQUIRED_ACTION=$(curl -s "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/authentication/required-actions/VERIFY_PROFILE" \ 38 | -H "Authorization: Bearer $ADMIN_TOKEN" | jq) 39 | 40 | echo $VERIFY_PROFILE_REQUIRED_ACTION 41 | echo 42 | 43 | echo "Disabling required action Verify Profile" 44 | echo "----------------------------------------" 45 | 46 | NEW_VERIFY_PROFILE_REQUIRED_ACTION=$(echo "$VERIFY_PROFILE_REQUIRED_ACTION" | jq '.enabled = false') 47 | 48 | echo $NEW_VERIFY_PROFILE_REQUIRED_ACTION 49 | echo 50 | 51 | curl -i -X PUT "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/authentication/required-actions/VERIFY_PROFILE" \ 52 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 53 | -H "Content-Type: application/json" \ 54 | -d "$NEW_VERIFY_PROFILE_REQUIRED_ACTION" 55 | 56 | echo "Creating movies-app client" 57 | echo "--------------------------" 58 | 59 | CLIENT_ID=$(curl -si -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/clients" \ 60 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 61 | -H "Content-Type: application/json" \ 62 | -d '{"clientId": "movies-app", "directAccessGrantsEnabled": true, "publicClient": true, "redirectUris": ["http://localhost:3000/*"]}' \ 63 | | grep -oE '[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}') 64 | 65 | echo "CLIENT_ID=$CLIENT_ID" 66 | echo 67 | 68 | echo "Creating the client role MOVIES_USER for the movies-app client" 69 | echo "--------------------------------------------------------------" 70 | 71 | curl -i -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/clients/$CLIENT_ID/roles" \ 72 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 73 | -H "Content-Type: application/json" \ 74 | -d '{"name": "MOVIES_USER"}' 75 | 76 | MOVIES_USER_CLIENT_ROLE_ID=$(curl -s http://localhost:8080/admin/realms/company-services/clients/$CLIENT_ID/roles/MOVIES_USER \ 77 | -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.id') 78 | 79 | echo "MOVIES_USER_CLIENT_ROLE_ID=$MOVIES_USER_CLIENT_ROLE_ID" 80 | echo 81 | 82 | echo "Creating the client role MOVIES_ADMIN for the movies-app client" 83 | echo "---------------------------------------------------------------" 84 | 85 | curl -i -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/clients/$CLIENT_ID/roles" \ 86 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 87 | -H "Content-Type: application/json" \ 88 | -d '{"name": "MOVIES_ADMIN"}' 89 | 90 | MOVIES_ADMIN_CLIENT_ROLE_ID=$(curl -s http://localhost:8080/admin/realms/company-services/clients/$CLIENT_ID/roles/MOVIES_ADMIN \ 91 | -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.id') 92 | 93 | echo "MOVIES_ADMIN_CLIENT_ROLE_ID=$MOVIES_ADMIN_CLIENT_ROLE_ID" 94 | echo 95 | 96 | echo "Creating USERS group" 97 | echo "--------------------" 98 | USERS_GROUP_ID=$(curl -si -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/groups" \ 99 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 100 | -H "Content-Type: application/json" \ 101 | -d '{"name": "USERS"}' \ 102 | | grep -oE '[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}') 103 | 104 | echo "USERS_GROUP_ID=$USERS_GROUP_ID" 105 | echo 106 | 107 | echo "Creating ADMIN group" 108 | echo "--------------------" 109 | ADMINS_GROUP_ID=$(curl -si -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/groups" \ 110 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 111 | -H "Content-Type: application/json" \ 112 | -d '{"name": "ADMINS"}' \ 113 | | grep -oE '[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}') 114 | 115 | echo "ADMINS_GROUP_ID=$ADMINS_GROUP_ID" 116 | echo 117 | 118 | echo "Adding USERS group as realm default group" 119 | echo "-----------------------------------------" 120 | 121 | curl -i -X PUT "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/default-groups/$USERS_GROUP_ID" \ 122 | -H "Authorization: Bearer $ADMIN_TOKEN" 123 | 124 | echo "Assigning MOVIES_USER client role to USERS group" 125 | echo "------------------------------------------------" 126 | 127 | curl -i -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/groups/$USERS_GROUP_ID/role-mappings/clients/$CLIENT_ID" \ 128 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 129 | -H "Content-Type: application/json" \ 130 | -d "[{\"id\": \"$MOVIES_USER_CLIENT_ROLE_ID\", \"name\": \"MOVIES_USER\"}]" 131 | 132 | echo "Assigning MOVIES_USER and MOVIES_ADMIN client roles to ADMINS group" 133 | echo "-------------------------------------------------------------------" 134 | 135 | curl -i -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/groups/$ADMINS_GROUP_ID/role-mappings/clients/$CLIENT_ID" \ 136 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 137 | -H "Content-Type: application/json" \ 138 | -d "[{\"id\": \"$MOVIES_USER_CLIENT_ROLE_ID\", \"name\": \"MOVIES_USER\"}, {\"id\": \"$MOVIES_ADMIN_CLIENT_ROLE_ID\", \"name\": \"MOVIES_ADMIN\"}]" 139 | 140 | echo "Creating 'user' user" 141 | echo "--------------------" 142 | 143 | USER_ID=$(curl -si -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/users" \ 144 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 145 | -H "Content-Type: application/json" \ 146 | -d '{"username": "user", "enabled": true, "credentials": [{"type": "password", "value": "user", "temporary": false}]}' \ 147 | | grep -oE '[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}') 148 | 149 | echo "USER_ID=$USER_ID" 150 | echo 151 | 152 | echo "Assigning USERS group to user" 153 | echo "-----------------------------" 154 | 155 | curl -i -X PUT "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/users/$USER_ID/groups/$USERS_GROUP_ID" \ 156 | -H "Authorization: Bearer $ADMIN_TOKEN" 157 | 158 | echo "Creating 'admin' user" 159 | echo "---------------------" 160 | 161 | ADMIN_ID=$(curl -si -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/users" \ 162 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 163 | -H "Content-Type: application/json" \ 164 | -d '{"username": "admin", "enabled": true, "credentials": [{"type": "password", "value": "admin", "temporary": false}]}' \ 165 | | grep -oE '[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}') 166 | 167 | echo "ADMIN_ID=$ADMIN_ID" 168 | echo 169 | 170 | echo "Assigning ADMINS group to admin" 171 | echo "-------------------------------" 172 | 173 | curl -i -X PUT "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/users/$ADMIN_ID/groups/$ADMINS_GROUP_ID" \ 174 | -H "Authorization: Bearer $ADMIN_TOKEN" 175 | 176 | echo "Getting user access token" 177 | echo "-------------------------" 178 | 179 | curl -s -X POST "http://$KEYCLOAK_HOST_PORT/realms/company-services/protocol/openid-connect/token" \ 180 | -H "Content-Type: application/x-www-form-urlencoded" \ 181 | -d "username=user" \ 182 | -d "password=user" \ 183 | -d "grant_type=password" \ 184 | -d "client_id=movies-app" | jq -r .access_token 185 | echo 186 | 187 | echo "Getting admin access token" 188 | echo "--------------------------" 189 | 190 | curl -s -X POST "http://$KEYCLOAK_HOST_PORT/realms/company-services/protocol/openid-connect/token" \ 191 | -H "Content-Type: application/x-www-form-urlencoded" \ 192 | -d "username=admin" \ 193 | -d "password=admin" \ 194 | -d "grant_type=password" \ 195 | -d "client_id=movies-app" | jq -r .access_token 196 | echo -------------------------------------------------------------------------------- /movies-api/.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 | -------------------------------------------------------------------------------- /movies-api/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 | -------------------------------------------------------------------------------- /movies-api/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 | -------------------------------------------------------------------------------- /movies-api/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 | movies-api 13 | 0.0.1-SNAPSHOT 14 | movies-api 15 | Demo project for Spring Boot 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 21 31 | 2.8.5 32 | 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-starter-data-mongodb 37 | 38 | 39 | org.springframework.boot 40 | spring-boot-starter-oauth2-resource-server 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 | 52 | 53 | org.springdoc 54 | springdoc-openapi-starter-webmvc-ui 55 | ${springdoc-openapi.version} 56 | 57 | 58 | 59 | org.projectlombok 60 | lombok 61 | true 62 | 63 | 64 | org.springframework.boot 65 | spring-boot-starter-test 66 | test 67 | 68 | 69 | 70 | 71 | 72 | 73 | org.apache.maven.plugins 74 | maven-compiler-plugin 75 | 76 | 77 | 78 | org.projectlombok 79 | lombok 80 | 81 | 82 | 83 | 84 | 85 | org.springframework.boot 86 | spring-boot-maven-plugin 87 | 88 | 89 | 90 | org.projectlombok 91 | lombok 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/MoviesApiApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class MoviesApiApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(MoviesApiApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/config/ErrorAttributesConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.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 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/config/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.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 io.swagger.v3.oas.models.security.SecurityScheme; 7 | import org.springdoc.core.models.GroupedOpenApi; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | 12 | @Configuration 13 | public class SwaggerConfig { 14 | 15 | @Value("${spring.application.name}") 16 | private String applicationName; 17 | 18 | @Bean 19 | OpenAPI customOpenAPI() { 20 | return new OpenAPI() 21 | .components( 22 | new Components().addSecuritySchemes(BEARER_KEY_SECURITY_SCHEME, 23 | new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"))) 24 | .info(new Info().title(applicationName)); 25 | } 26 | 27 | @Bean 28 | GroupedOpenApi customApi() { 29 | return GroupedOpenApi.builder().group("api").pathsToMatch("/api/**").build(); 30 | } 31 | 32 | public static final String BEARER_KEY_SECURITY_SCHEME = "bearer-key"; 33 | } 34 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/movie/MovieRepository.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.movie; 2 | 3 | import com.ivanfranchin.moviesapi.movie.model.Movie; 4 | import org.springframework.data.mongodb.repository.MongoRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | public interface MovieRepository extends MongoRepository { 9 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/movie/MovieService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.movie; 2 | 3 | import com.ivanfranchin.moviesapi.movie.exception.MovieNotFoundException; 4 | import com.ivanfranchin.moviesapi.movie.model.Movie; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.util.List; 9 | 10 | @RequiredArgsConstructor 11 | @Service 12 | public class MovieService { 13 | 14 | private final MovieRepository movieRepository; 15 | 16 | public Movie validateAndGetMovie(String imdbId) { 17 | return movieRepository.findById(imdbId).orElseThrow(() -> new MovieNotFoundException(imdbId)); 18 | } 19 | 20 | public List getMovies() { 21 | return movieRepository.findAll(); 22 | } 23 | 24 | public Movie saveMovie(Movie movie) { 25 | return movieRepository.save(movie); 26 | } 27 | 28 | public void deleteMovie(Movie movie) { 29 | movieRepository.delete(movie); 30 | } 31 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/movie/MoviesController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.movie; 2 | 3 | import com.ivanfranchin.moviesapi.movie.dto.AddCommentRequest; 4 | import com.ivanfranchin.moviesapi.movie.dto.CreateMovieRequest; 5 | import com.ivanfranchin.moviesapi.movie.dto.MovieDto; 6 | import com.ivanfranchin.moviesapi.movie.mapper.MovieDtoMapper; 7 | import com.ivanfranchin.moviesapi.movie.model.Movie; 8 | import com.ivanfranchin.moviesapi.userextra.dto.UpdateMovieRequest; 9 | import io.swagger.v3.oas.annotations.Operation; 10 | import io.swagger.v3.oas.annotations.security.SecurityRequirement; 11 | import jakarta.validation.Valid; 12 | import lombok.RequiredArgsConstructor; 13 | import org.springframework.http.HttpStatus; 14 | import org.springframework.web.bind.annotation.DeleteMapping; 15 | import org.springframework.web.bind.annotation.GetMapping; 16 | import org.springframework.web.bind.annotation.PathVariable; 17 | import org.springframework.web.bind.annotation.PostMapping; 18 | import org.springframework.web.bind.annotation.PutMapping; 19 | import org.springframework.web.bind.annotation.RequestBody; 20 | import org.springframework.web.bind.annotation.RequestMapping; 21 | import org.springframework.web.bind.annotation.ResponseStatus; 22 | import org.springframework.web.bind.annotation.RestController; 23 | 24 | import java.security.Principal; 25 | import java.time.Instant; 26 | import java.util.List; 27 | 28 | import static com.ivanfranchin.moviesapi.config.SwaggerConfig.BEARER_KEY_SECURITY_SCHEME; 29 | 30 | @RequiredArgsConstructor 31 | @RestController 32 | @RequestMapping("/api/movies") 33 | public class MoviesController { 34 | 35 | private final MovieService movieService; 36 | private final MovieDtoMapper movieMapper; 37 | 38 | @GetMapping 39 | public List getMovies() { 40 | return movieService.getMovies().stream().map(movieMapper::toMovieDto).toList(); 41 | } 42 | 43 | @GetMapping("/{imdbId}") 44 | public MovieDto getMovie(@PathVariable String imdbId) { 45 | Movie movie = movieService.validateAndGetMovie(imdbId); 46 | return movieMapper.toMovieDto(movie); 47 | } 48 | 49 | @Operation(security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 50 | @ResponseStatus(HttpStatus.CREATED) 51 | @PostMapping 52 | public MovieDto createMovie(@Valid @RequestBody CreateMovieRequest createMovieRequest) { 53 | Movie movie = Movie.from(createMovieRequest); 54 | movie = movieService.saveMovie(movie); 55 | return movieMapper.toMovieDto(movie); 56 | } 57 | 58 | @Operation(security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 59 | @PutMapping("/{imdbId}") 60 | public MovieDto updateMovie(@PathVariable String imdbId, @Valid @RequestBody UpdateMovieRequest updateMovieRequest) { 61 | Movie movie = movieService.validateAndGetMovie(imdbId); 62 | Movie.updateFrom(updateMovieRequest, movie); 63 | movie = movieService.saveMovie(movie); 64 | return movieMapper.toMovieDto(movie); 65 | } 66 | 67 | @Operation(security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 68 | @DeleteMapping("/{imdbId}") 69 | public MovieDto deleteMovie(@PathVariable String imdbId) { 70 | Movie movie = movieService.validateAndGetMovie(imdbId); 71 | movieService.deleteMovie(movie); 72 | return movieMapper.toMovieDto(movie); 73 | } 74 | 75 | @Operation(security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 76 | @ResponseStatus(HttpStatus.CREATED) 77 | @PostMapping("/{imdbId}/comments") 78 | public MovieDto addMovieComment(@PathVariable String imdbId, 79 | @Valid @RequestBody AddCommentRequest addCommentRequest, 80 | Principal principal) { 81 | Movie movie = movieService.validateAndGetMovie(imdbId); 82 | Movie.Comment comment = new Movie.Comment(principal.getName(), addCommentRequest.text(), Instant.now()); 83 | movie.getComments().addFirst(comment); 84 | movie = movieService.saveMovie(movie); 85 | return movieMapper.toMovieDto(movie); 86 | } 87 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/movie/dto/AddCommentRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.movie.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | public record AddCommentRequest(@Schema(example = "Very good!") @NotBlank String text) { 7 | } 8 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/movie/dto/CreateMovieRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.movie.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | public record CreateMovieRequest( 7 | @Schema(example = "tt0120804") @NotBlank String imdbId, 8 | @Schema(example = "Resident Evil") @NotBlank String title, 9 | @Schema(example = "Paul W.S. Anderson", description = "Set \"N/A\" if the director of the movie is unknown") @NotBlank String director, 10 | @Schema(example = "2002", description = "Set \"N/A\" if the year of the movie is unknown") @NotBlank String year, 11 | @Schema(example = "https://m.media-amazon.com/images/M/MV5BN2Y2MTljNjMtMDRlNi00ZWNhLThmMWItYTlmZjYyZDk4NzYxXkEyXkFqcGdeQXVyNjQ2MjQ5NzM@._V1_SX300.jpg") String poster) { 12 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/movie/dto/MovieDto.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.movie.dto; 2 | 3 | import java.time.Instant; 4 | import java.util.List; 5 | 6 | public record MovieDto(String imdbId, String title, String director, String year, String poster, 7 | List comments) { 8 | 9 | public record CommentDto(String username, String avatar, String text, Instant timestamp) { 10 | } 11 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/movie/exception/MovieNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.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 imdbId) { 10 | super("Movie with imdbId '%s' not found".formatted(imdbId)); 11 | } 12 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/movie/mapper/MovieDtoMapper.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.movie.mapper; 2 | 3 | import com.ivanfranchin.moviesapi.movie.dto.MovieDto; 4 | import com.ivanfranchin.moviesapi.movie.model.Movie; 5 | import com.ivanfranchin.moviesapi.userextra.UserExtraService; 6 | import com.ivanfranchin.moviesapi.userextra.model.UserExtra; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.stereotype.Component; 9 | 10 | import java.time.Instant; 11 | import java.util.List; 12 | 13 | @RequiredArgsConstructor 14 | @Component 15 | public class MovieDtoMapper { 16 | 17 | private final UserExtraService userExtraService; 18 | 19 | public MovieDto toMovieDto(Movie movie) { 20 | List comments = movie.getComments().stream() 21 | .map(this::toMovieDtoCommentDto) 22 | .toList(); 23 | 24 | return new MovieDto( 25 | movie.getImdbId(), 26 | movie.getTitle(), 27 | movie.getDirector(), 28 | movie.getYear(), 29 | movie.getPoster(), 30 | comments 31 | ); 32 | } 33 | 34 | public MovieDto.CommentDto toMovieDtoCommentDto(Movie.Comment comment) { 35 | String username = comment.getUsername(); 36 | String avatar = getAvatarForUser(username); 37 | String text = comment.getText(); 38 | Instant timestamp = comment.getTimestamp(); 39 | 40 | return new MovieDto.CommentDto(username, avatar, text, timestamp); 41 | } 42 | 43 | private String getAvatarForUser(String username) { 44 | return userExtraService.getUserExtra(username) 45 | .map(UserExtra::getAvatar) 46 | .orElse(username); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/movie/model/Movie.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.movie.model; 2 | 3 | import com.ivanfranchin.moviesapi.movie.dto.CreateMovieRequest; 4 | import com.ivanfranchin.moviesapi.userextra.dto.UpdateMovieRequest; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import org.springframework.data.annotation.Id; 8 | import org.springframework.data.mongodb.core.mapping.Document; 9 | 10 | import java.time.Instant; 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | 14 | @Data 15 | @Document(collection = "movies") 16 | public class Movie { 17 | 18 | @Id 19 | private String imdbId; 20 | private String title; 21 | private String director; 22 | private String year; 23 | private String poster; 24 | private List comments = new ArrayList<>(); 25 | 26 | @Data 27 | @AllArgsConstructor 28 | public static class Comment { 29 | private String username; 30 | private String text; 31 | private Instant timestamp; 32 | } 33 | 34 | public static Movie from(CreateMovieRequest createMovieRequest) { 35 | Movie movie = new Movie(); 36 | movie.setImdbId(createMovieRequest.imdbId()); 37 | movie.setTitle(createMovieRequest.title()); 38 | movie.setDirector(createMovieRequest.director()); 39 | movie.setYear(createMovieRequest.year()); 40 | movie.setPoster(createMovieRequest.poster()); 41 | return movie; 42 | } 43 | 44 | public static void updateFrom(UpdateMovieRequest updateMovieRequest, Movie movie) { 45 | if (updateMovieRequest.title() != null) { 46 | movie.setTitle(updateMovieRequest.title()); 47 | } 48 | if (updateMovieRequest.director() != null) { 49 | movie.setDirector(updateMovieRequest.director()); 50 | } 51 | if (updateMovieRequest.year() != null) { 52 | movie.setYear(updateMovieRequest.year()); 53 | } 54 | if (updateMovieRequest.poster() != null) { 55 | movie.setPoster(updateMovieRequest.poster()); 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/security/CorsConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.security; 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.cors.CorsConfiguration; 7 | import org.springframework.web.cors.CorsConfigurationSource; 8 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 9 | 10 | import java.util.List; 11 | 12 | @Configuration 13 | public class CorsConfig { 14 | 15 | @Bean 16 | CorsConfigurationSource corsConfigurationSource(@Value("${app.cors.allowed-origins}") List allowedOrigins) { 17 | CorsConfiguration configuration = new CorsConfiguration(); 18 | configuration.setAllowCredentials(true); 19 | configuration.setAllowedOrigins(allowedOrigins); 20 | configuration.addAllowedMethod("*"); 21 | configuration.addAllowedHeader("*"); 22 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 23 | source.registerCorsConfiguration("/**", configuration); 24 | return source; 25 | } 26 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/security/JwtAuthConverter.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.security; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.core.convert.converter.Converter; 5 | import org.springframework.security.authentication.AbstractAuthenticationToken; 6 | import org.springframework.security.core.GrantedAuthority; 7 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 8 | import org.springframework.security.oauth2.jwt.Jwt; 9 | import org.springframework.security.oauth2.jwt.JwtClaimNames; 10 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; 11 | import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; 12 | import org.springframework.stereotype.Component; 13 | 14 | import java.util.Collection; 15 | import java.util.Map; 16 | import java.util.Set; 17 | import java.util.stream.Collectors; 18 | import java.util.stream.Stream; 19 | 20 | @RequiredArgsConstructor 21 | @Component 22 | public class JwtAuthConverter implements Converter { 23 | 24 | private static final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); 25 | 26 | private final JwtAuthConverterProperties properties; 27 | 28 | @Override 29 | public AbstractAuthenticationToken convert(Jwt jwt) { 30 | Collection authorities = 31 | Stream.concat(jwtGrantedAuthoritiesConverter.convert(jwt).stream(), extractResourceRoles(jwt).stream()).collect(Collectors.toSet()); 32 | String claimName = properties.getPrincipalAttribute() == null ? JwtClaimNames.SUB : properties.getPrincipalAttribute(); 33 | return new JwtAuthenticationToken(jwt, authorities, jwt.getClaim(claimName)); 34 | } 35 | 36 | private Collection extractResourceRoles(Jwt jwt) { 37 | Map resourceAccess = jwt.getClaim("resource_access"); 38 | Map resource; 39 | Collection resourceRoles; 40 | if (resourceAccess == null 41 | || (resource = (Map) resourceAccess.get(properties.getResourceId())) == null 42 | || (resourceRoles = (Collection) resource.get("roles")) == null) { 43 | return Set.of(); 44 | } 45 | return resourceRoles.stream() 46 | .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) 47 | .collect(Collectors.toSet()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/security/JwtAuthConverterProperties.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.security; 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 = "jwt.auth.converter") 13 | public class JwtAuthConverterProperties { 14 | 15 | @NotBlank 16 | private String resourceId; 17 | private String principalAttribute; 18 | } 19 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/security/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.security; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.http.HttpMethod; 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 8 | import org.springframework.security.config.http.SessionCreationPolicy; 9 | import org.springframework.security.web.SecurityFilterChain; 10 | 11 | @RequiredArgsConstructor 12 | @Configuration 13 | public class SecurityConfig { 14 | 15 | private final JwtAuthConverter jwtAuthConverter; 16 | 17 | @Bean 18 | SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 19 | return http 20 | .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests 21 | .requestMatchers(HttpMethod.GET, "/api/movies", "/api/movies/**").permitAll() 22 | .requestMatchers("/api/movies/*/comments").hasAnyRole(MOVIES_ADMIN, MOVIES_USER) 23 | .requestMatchers("/api/movies", "/api/movies/**").hasRole(MOVIES_ADMIN) 24 | .requestMatchers("/api/userextras/me").hasAnyRole(MOVIES_ADMIN, MOVIES_USER) 25 | .requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs", "/v3/api-docs/**").permitAll() 26 | .anyRequest().authenticated()) 27 | .oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt( 28 | jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter))) 29 | .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 30 | .build(); 31 | } 32 | 33 | public static final String MOVIES_ADMIN = "MOVIES_ADMIN"; 34 | public static final String MOVIES_USER = "MOVIES_USER"; 35 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/userextra/UserExtraController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.userextra; 2 | 3 | import com.ivanfranchin.moviesapi.userextra.dto.UserExtraRequest; 4 | import com.ivanfranchin.moviesapi.userextra.model.UserExtra; 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.security.SecurityRequirement; 7 | import jakarta.validation.Valid; 8 | import lombok.RequiredArgsConstructor; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.PostMapping; 11 | import org.springframework.web.bind.annotation.RequestBody; 12 | import org.springframework.web.bind.annotation.RequestMapping; 13 | import org.springframework.web.bind.annotation.RestController; 14 | 15 | import java.security.Principal; 16 | import java.util.Optional; 17 | 18 | import static com.ivanfranchin.moviesapi.config.SwaggerConfig.BEARER_KEY_SECURITY_SCHEME; 19 | 20 | @RequiredArgsConstructor 21 | @RestController 22 | @RequestMapping("/api/userextras") 23 | public class UserExtraController { 24 | 25 | private final UserExtraService userExtraService; 26 | 27 | @Operation(security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 28 | @GetMapping("/me") 29 | public UserExtra getUserExtra(Principal principal) { 30 | return userExtraService.validateAndGetUserExtra(principal.getName()); 31 | } 32 | 33 | @Operation(security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 34 | @PostMapping("/me") 35 | public UserExtra saveUserExtra(@Valid @RequestBody UserExtraRequest updateUserExtraRequest, 36 | Principal principal) { 37 | Optional userExtraOptional = userExtraService.getUserExtra(principal.getName()); 38 | UserExtra userExtra = userExtraOptional.orElseGet(() -> new UserExtra(principal.getName())); 39 | userExtra.setAvatar(updateUserExtraRequest.avatar()); 40 | return userExtraService.saveUserExtra(userExtra); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/userextra/UserExtraRepository.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.userextra; 2 | 3 | import com.ivanfranchin.moviesapi.userextra.model.UserExtra; 4 | import org.springframework.data.mongodb.repository.MongoRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | public interface UserExtraRepository extends MongoRepository { 9 | } 10 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/userextra/UserExtraService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.userextra; 2 | 3 | import com.ivanfranchin.moviesapi.userextra.exception.UserExtraNotFoundException; 4 | import com.ivanfranchin.moviesapi.userextra.model.UserExtra; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.util.Optional; 9 | 10 | @RequiredArgsConstructor 11 | @Service 12 | public class UserExtraService { 13 | 14 | private final UserExtraRepository userExtraRepository; 15 | 16 | public UserExtra validateAndGetUserExtra(String username) { 17 | return getUserExtra(username).orElseThrow(() -> new UserExtraNotFoundException(username)); 18 | } 19 | 20 | public Optional getUserExtra(String username) { 21 | return userExtraRepository.findById(username); 22 | } 23 | 24 | public UserExtra saveUserExtra(UserExtra userExtra) { 25 | return userExtraRepository.save(userExtra); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/userextra/dto/UpdateMovieRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.userextra.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | 5 | public record UpdateMovieRequest( 6 | @Schema(example = "Resident Evil: Apocalypse") String title, 7 | @Schema(example = "Paul W.S. Anderson", description = "Set \"N/A\" if the director of the movie is unknown") String director, 8 | @Schema(example = "2004", description = "Set \"N/A\" if the year of the movie is unknown") String year, 9 | @Schema(example = "https://m.media-amazon.com/images/M/MV5BMTc1NTUxMzk0Nl5BMl5BanBnXkFtZTcwNDQ1MDIzMw@@._V1_SX300.jpg") String poster) { 10 | } -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/userextra/dto/UserExtraRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.userextra.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | 5 | public record UserExtraRequest(@Schema(example = "avatar") String avatar) { 6 | } 7 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/userextra/exception/UserExtraNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.userextra.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 UserExtraNotFoundException extends RuntimeException { 8 | 9 | public UserExtraNotFoundException(String username) { 10 | super("UserExtra of %s not found".formatted(username)); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /movies-api/src/main/java/com/ivanfranchin/moviesapi/userextra/model/UserExtra.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi.userextra.model; 2 | 3 | import lombok.Data; 4 | import org.springframework.data.annotation.Id; 5 | import org.springframework.data.mongodb.core.mapping.Document; 6 | 7 | @Data 8 | @Document(collection = "userextras") 9 | public class UserExtra { 10 | 11 | @Id 12 | private String username; 13 | private String avatar; 14 | 15 | public UserExtra(String username) { 16 | this.username = username; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /movies-api/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=movies-api 2 | 3 | spring.data.mongodb.uri=mongodb://localhost:27017/moviesdb 4 | 5 | spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8080/realms/company-services 6 | 7 | jwt.auth.converter.resource-id=movies-app 8 | jwt.auth.converter.principal-attribute=preferred_username 9 | 10 | app.cors.allowed-origins=http://localhost:3000 11 | 12 | springdoc.swagger-ui.disable-swagger-default-url=true 13 | 14 | logging.level.org.springframework.security=DEBUG -------------------------------------------------------------------------------- /movies-api/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ _ 2 | _ __ ___ _____ _(_) ___ ___ __ _ _ __ (_) 3 | | '_ ` _ \ / _ \ \ / / |/ _ \/ __|_____ / _` | '_ \| | 4 | | | | | | | (_) \ V /| | __/\__ \_____| (_| | |_) | | 5 | |_| |_| |_|\___/ \_/ |_|\___||___/ \__,_| .__/|_| 6 | |_| 7 | :: Spring Boot :: ${spring-boot.formatted-version} 8 | -------------------------------------------------------------------------------- /movies-api/src/test/java/com/ivanfranchin/moviesapi/MoviesApiApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.moviesapi; 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 MoviesApiApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /movies-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "movies-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@react-keycloak/web": "^3.4.0", 7 | "@testing-library/jest-dom": "^6.6.3", 8 | "@testing-library/react": "^16.2.0", 9 | "@testing-library/user-event": "^14.6.1", 10 | "axios": "^1.8.1", 11 | "keycloak-js": "^26.1.3", 12 | "moment": "^2.30.1", 13 | "react": "^18.3.1", 14 | "react-dom": "^18.3.1", 15 | "react-moment": "^1.1.3", 16 | "react-router-dom": "^7.2.0", 17 | "react-scripts": "5.0.1", 18 | "semantic-ui-react": "^2.1.5", 19 | "web-vitals": "^4.2.4" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /movies-ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-react-keycloak/ff77df650683377b1684b398f5639342851beead/movies-ui/public/favicon.ico -------------------------------------------------------------------------------- /movies-ui/public/images/movie-poster.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-react-keycloak/ff77df650683377b1684b398f5639342851beead/movies-ui/public/images/movie-poster.jpg -------------------------------------------------------------------------------- /movies-ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | 23 | Movies UI 24 | 25 | 26 | 27 |
28 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /movies-ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /movies-ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /movies-ui/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ReactKeycloakProvider } from '@react-keycloak/web' 3 | import Keycloak from 'keycloak-js' 4 | import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' 5 | import Home from './components/home/Home' 6 | import { moviesApi } from './components/misc/MoviesApi' 7 | import Navbar from './components/misc/Navbar' 8 | import PrivateRoute from './components/misc/PrivateRoute' 9 | import MoviesPage from './components/movies/MoviesPage' 10 | import UserSettings from './components/settings/UserSettings' 11 | import MovieWizard from './components/wizard/MovieWizard' 12 | import MovieDetail from './components/movie/MovieDetail' 13 | import { Dimmer, Header, Icon } from 'semantic-ui-react' 14 | import { config } from './Constants' 15 | 16 | function App() { 17 | const keycloak = new Keycloak({ 18 | url: `${config.url.KEYCLOAK_BASE_URL}`, 19 | realm: "company-services", 20 | clientId: "movies-app" 21 | }) 22 | const initOptions = { pkceMethod: 'S256' } 23 | 24 | const handleOnEvent = async (event, error) => { 25 | if (event === 'onAuthSuccess') { 26 | if (keycloak.authenticated) { 27 | let response = await moviesApi.getUserExtrasMe(keycloak.token) 28 | if (response.status === 404) { 29 | const username = keycloak.tokenParsed.preferred_username 30 | const userExtra = { avatar: username } 31 | response = await moviesApi.saveUserExtrasMe(keycloak.token, userExtra) 32 | console.log('UserExtra created for ' + username) 33 | } 34 | keycloak['avatar'] = response.data.avatar 35 | } 36 | } 37 | } 38 | 39 | const loadingComponent = ( 40 | 41 |
42 | 43 | Keycloak is loading 44 | or running authorization code flow with PKCE 45 | 46 |
47 |
48 | ) 49 | 50 | return ( 51 | handleOnEvent(event, error)} 56 | > 57 | 58 | 59 | 60 | } /> 61 | } /> 62 | } /> 63 | } /> 64 | } /> 65 | } /> 66 | } /> 67 | 68 | 69 | 70 | ) 71 | } 72 | 73 | export default App -------------------------------------------------------------------------------- /movies-ui/src/Constants.js: -------------------------------------------------------------------------------- 1 | const prod = { 2 | url: { 3 | KEYCLOAK_BASE_URL: "https://keycloak.herokuapp.com", 4 | API_BASE_URL: 'https://myapp.herokuapp.com', 5 | OMDB_BASE_URL: 'https://www.omdbapi.com', 6 | AVATARS_DICEBEAR_URL: 'https://api.dicebear.com/6.x' 7 | } 8 | } 9 | 10 | const dev = { 11 | url: { 12 | KEYCLOAK_BASE_URL: "http://localhost:8080", 13 | API_BASE_URL: 'http://localhost:9080', 14 | OMDB_BASE_URL: 'https://www.omdbapi.com', 15 | AVATARS_DICEBEAR_URL: 'https://api.dicebear.com/6.x' 16 | } 17 | } 18 | 19 | export const config = process.env.NODE_ENV === 'development' ? dev : prod -------------------------------------------------------------------------------- /movies-ui/src/components/home/Home.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Container } from 'semantic-ui-react' 3 | import { handleLogError } from '../misc/Helpers' 4 | import { moviesApi } from '../misc/MoviesApi' 5 | import MovieList from './MovieList' 6 | 7 | function Home() { 8 | const [isLoading, setIsLoading] = useState(false) 9 | const [movies, setMovies] = useState([]) 10 | 11 | useEffect(() => { 12 | const fetchMovies = async () => { 13 | setIsLoading(true) 14 | try { 15 | const response = await moviesApi.getMovies() 16 | const movies = response.data 17 | 18 | setMovies(movies) 19 | } catch (error) { 20 | handleLogError(error) 21 | } finally { 22 | setIsLoading(false) 23 | } 24 | } 25 | fetchMovies() 26 | }, []) 27 | 28 | return ( 29 | isLoading ? <> : ( 30 | 31 | 32 | 33 | ) 34 | ) 35 | } 36 | 37 | export default Home -------------------------------------------------------------------------------- /movies-ui/src/components/home/MovieCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Card, Image } from 'semantic-ui-react' 3 | import { Link } from 'react-router-dom' 4 | 5 | function MovieCard({ movie, link }) { 6 | const content = ( 7 | <> 8 | 9 | 10 | {movie.title} 11 | 12 | 13 | imdbID: {movie.imdbId} 14 | Author: {movie.director} 15 | Year: {movie.year} 16 | 17 | 18 | ) 19 | return ( 20 | !link ? {content} : {content} 21 | ) 22 | } 23 | 24 | export default MovieCard -------------------------------------------------------------------------------- /movies-ui/src/components/home/MovieList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Card, Header, Segment } from 'semantic-ui-react' 3 | import MovieCard from './MovieCard' 4 | 5 | function MovieList({ movies }) { 6 | const movieList = movies.map(movie => ) 7 | 8 | return ( 9 | movies.length > 0 ? ( 10 | 11 | {movieList} 12 | 13 | ) : ( 14 | 15 |
No movies
16 |
17 | ) 18 | ) 19 | } 20 | 21 | export default MovieList -------------------------------------------------------------------------------- /movies-ui/src/components/misc/ConfirmationModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button, Modal } from 'semantic-ui-react' 3 | 4 | function ConfirmationModal({ modal, movie }) { 5 | const { isOpen, header, content, onClose, onAction } = modal 6 | return ( 7 | 8 | {header} 9 | 10 |

{content}

11 |
12 | 13 | 48 | 49 | 50 | 51 | 52 | ) 53 | } 54 | 55 | export default MoviesForm -------------------------------------------------------------------------------- /movies-ui/src/components/movies/MoviesPage.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { Container, Grid, Header, Segment, Icon, Divider } from 'semantic-ui-react' 3 | import { handleLogError } from '../misc/Helpers' 4 | import { moviesApi } from '../misc/MoviesApi' 5 | import MoviesForm from './MoviesForm' 6 | import MoviesTable from './MoviesTable' 7 | import { isAdmin } from '../misc/Helpers' 8 | import { Navigate } from 'react-router-dom' 9 | import ConfirmationModal from '../misc/ConfirmationModal' 10 | import { useKeycloak } from '@react-keycloak/web' 11 | 12 | const formInitialState = { 13 | imdbId: '', 14 | title: '', 15 | director: '', 16 | year: '', 17 | poster: '', 18 | 19 | imdbIdError: false, 20 | titleError: false, 21 | directorError: false, 22 | yearError: false 23 | } 24 | 25 | const modalInitialState = { 26 | isOpen: false, 27 | header: '', 28 | content: '', 29 | onAction: null, 30 | onClose: null 31 | } 32 | 33 | function MoviesPage() { 34 | 35 | const [movies, setMovies] = useState([]) 36 | const [form, setForm] = useState({ ...formInitialState }) 37 | const [modal, setModal] = useState({ ...modalInitialState }) 38 | const [movieToBeDeleted, setMovieToBeDeleted] = useState(null) 39 | 40 | const { keycloak } = useKeycloak() 41 | 42 | useEffect(() => { 43 | handleGetMovies() 44 | }, []) 45 | 46 | const handleChange = (e) => { 47 | const { id, value } = e.target 48 | setForm((prevForm) => ({ ...prevForm, [id]: value })) 49 | } 50 | 51 | const handleGetMovies = async () => { 52 | try { 53 | const response = await moviesApi.getMovies() 54 | const movies = response.data 55 | setMovies(movies) 56 | } catch (error) { 57 | handleLogError(error) 58 | } 59 | } 60 | 61 | const handleSaveMovie = async () => { 62 | if (!isValidForm()) { 63 | return 64 | } 65 | 66 | const { imdbId, title, director, year, poster } = form 67 | const movie = { imdbId, title, director, year, poster } 68 | try { 69 | await moviesApi.saveMovie(movie, keycloak.token) 70 | clearForm() 71 | handleGetMovies() 72 | } catch (error) { 73 | handleLogError(error) 74 | } 75 | } 76 | 77 | const handleDeleteMovie = (movie) => { 78 | const modal = { 79 | isOpen: true, 80 | header: 'Delete Movie', 81 | content: `Would you like to delete movie '${movie.title}'?`, 82 | onAction: handleActionModal, 83 | onClose: handleCloseModal 84 | } 85 | setMovieToBeDeleted(movie) 86 | setModal(modal) 87 | // The deletion is done in handleActionModal function 88 | } 89 | 90 | const handleEditMovie = (movie) => { 91 | const form = { 92 | imdbId: movie.imdbId, 93 | title: movie.title, 94 | director: movie.director, 95 | year: movie.year, 96 | poster: movie.poster, 97 | imdbIdError: false, 98 | titleError: false, 99 | directorError: false, 100 | yearError: false 101 | } 102 | setForm(form) 103 | } 104 | 105 | const clearForm = () => { 106 | setForm({ ...formInitialState }) 107 | } 108 | 109 | const isValidForm = () => { 110 | const imdbIdError = form.imdbId.trim() === '' 111 | const titleError = form.title.trim() === '' 112 | const directorError = form.director.trim() === '' 113 | const yearError = form.year.trim() === '' 114 | 115 | setForm((prevForm) => ({ 116 | ...prevForm, 117 | imdbIdError, 118 | titleError, 119 | directorError, 120 | yearError 121 | })) 122 | 123 | return !(imdbIdError || titleError || directorError || yearError) 124 | } 125 | 126 | const handleActionModal = async (response, movie) => { 127 | if (response) { 128 | try { 129 | await moviesApi.deleteMovie(movie.imdbId, keycloak.token) 130 | handleGetMovies() 131 | } catch (error) { 132 | handleLogError(error) 133 | } 134 | } 135 | handleCloseModal() 136 | } 137 | 138 | const handleCloseModal = () => { 139 | setModal({ ...modalInitialState }) 140 | setMovieToBeDeleted(null) 141 | } 142 | 143 | if (!isAdmin(keycloak)) { 144 | return 145 | } 146 | 147 | return ( 148 | 149 | 150 | 151 | 152 |
153 | 154 | Movies 155 |
156 | 157 | 163 |
164 |
165 | 166 | 171 | 172 |
173 | 174 | 178 |
179 | ) 180 | } 181 | 182 | export default MoviesPage -------------------------------------------------------------------------------- /movies-ui/src/components/movies/MoviesTable.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button, Image, Table } from 'semantic-ui-react' 3 | 4 | function MoviesTable({ movies, handleDeleteMovie, handleEditMovie }) { 5 | const height = window.innerHeight - 100 6 | const style = { 7 | height: height, 8 | maxHeight: height, 9 | overflowY: 'auto', 10 | overflowX: 'hidden' 11 | } 12 | 13 | const movieList = movies && movies.map(movie => { 14 | return ( 15 | 16 | 17 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | ) 85 | } 86 | 87 | export default UserSettings 88 | -------------------------------------------------------------------------------- /movies-ui/src/components/wizard/CompleteStep.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Card } from 'semantic-ui-react' 3 | import MovieCard from '../home/MovieCard' 4 | 5 | function CompleteStep({ movie }) { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | 13 | export default CompleteStep -------------------------------------------------------------------------------- /movies-ui/src/components/wizard/FormStep.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Form, Segment } from 'semantic-ui-react' 3 | 4 | function FormStep({ imdbId, title, director, year, poster, imdbIdError, titleError, directorError, yearError, handleChange }) { 5 | return ( 6 | 7 |
8 | 15 | 23 | 31 | 38 | 45 | 46 |
47 | ) 48 | } 49 | 50 | export default FormStep 51 | -------------------------------------------------------------------------------- /movies-ui/src/components/wizard/MovieWizard.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { Button, Container, Grid, Icon, Step, Divider } from 'semantic-ui-react' 3 | import { handleLogError } from '../misc/Helpers' 4 | import { moviesApi } from '../misc/MoviesApi' 5 | import { omdbApi } from '../misc/OmdbApi' 6 | import CompleteStep from './CompleteStep' 7 | import FormStep from './FormStep' 8 | import SearchStep from './SearchStep' 9 | import { Navigate } from 'react-router-dom' 10 | import { isAdmin } from '../misc/Helpers' 11 | import { useNavigate } from 'react-router-dom' 12 | import { useKeycloak } from '@react-keycloak/web' 13 | 14 | function MovieWizard() { 15 | 16 | const [step, setStep] = useState(1) 17 | 18 | // Search Step 19 | const [isLoading, setIsLoading] = useState(false) 20 | const [searchText, setSearchText] = useState('') 21 | const [movies, setMovies] = useState([]) 22 | const [selectedMovie, setSelectedMovie] = useState(null) 23 | 24 | // Form Step 25 | const [imdbId, setImdbId] = useState('') 26 | const [title, setTitle] = useState('') 27 | const [director, setDirector] = useState('') 28 | const [year, setYear] = useState('') 29 | const [poster, setPoster] = useState('') 30 | const [imdbIdError, setImdbIdError] = useState(false) 31 | const [titleError, setTitleError] = useState(false) 32 | const [directorError, setDirectorError] = useState(false) 33 | const [yearError, setYearError] = useState(false) 34 | 35 | const navigate = useNavigate() 36 | const { keycloak } = useKeycloak() 37 | 38 | const handlePreviousStep = () => { 39 | if (step === 2) { 40 | setImdbIdError(false) 41 | setTitleError(false) 42 | setDirectorError(false) 43 | setYearError(false) 44 | } 45 | setStep(step > 1 ? step - 1 : step) 46 | } 47 | 48 | const handleNextStep = () => { 49 | if (step === 2 && !isValidForm()) { 50 | return 51 | } 52 | setStep(step < 3 ? step + 1 : step) 53 | } 54 | 55 | const handleChange = (e) => { 56 | const { id, value } = e.target; 57 | if (id === 'searchText') { 58 | setSearchText(value); 59 | } else if (id === 'imdbId') { 60 | setImdbId(value); 61 | } else if (id === 'title') { 62 | setTitle(value); 63 | } else if (id === 'director') { 64 | setDirector(value); 65 | } else if (id === 'year') { 66 | setYear(value); 67 | } else if (id === 'poster') { 68 | setPoster(value); 69 | } 70 | } 71 | 72 | const handleTableSelection = (movie) => { 73 | if (movie && selectedMovie && movie.imdbId === selectedMovie.imdbId) { 74 | setSelectedMovie(null) 75 | setImdbId('') 76 | setTitle('') 77 | setDirector('') 78 | setYear('') 79 | setPoster('') 80 | } else { 81 | setSelectedMovie(movie) 82 | setImdbId(movie.imdbId) 83 | setTitle(movie.title) 84 | setDirector(movie.director) 85 | setYear(movie.year) 86 | setPoster(movie.poster) 87 | } 88 | } 89 | 90 | const handleSearchMovies = async () => { 91 | try { 92 | setIsLoading(true) 93 | const response = await omdbApi.getMovies(searchText) 94 | let moviesArr = [] 95 | const { Error } = response.data 96 | if (Error) { 97 | console.log(Error) 98 | } else { 99 | const movie = { 100 | imdbId: response.data.imdbID, 101 | title: response.data.Title, 102 | director: response.data.Director, 103 | year: response.data.Year, 104 | poster: response.data.Poster 105 | } 106 | moviesArr.push(movie) 107 | } 108 | setMovies(moviesArr) 109 | } catch (error) { 110 | handleLogError(error) 111 | } finally { 112 | setIsLoading(false) 113 | } 114 | } 115 | 116 | const handleCreateMovie = async () => { 117 | const movie = { imdbId, title, director, year, poster } 118 | try { 119 | await moviesApi.saveMovie(movie, keycloak.token) 120 | navigate("/home") 121 | } catch (error) { 122 | handleLogError(error) 123 | } 124 | } 125 | 126 | const isValidForm = () => { 127 | const imdbIdError = imdbId.trim() === '' 128 | const titleError = title.trim() === '' 129 | const directorError = director.trim() === '' 130 | const yearError = year.trim() === '' 131 | 132 | setImdbIdError(imdbIdError) 133 | setTitleError(titleError) 134 | setDirectorError(directorError) 135 | setYearError(yearError) 136 | 137 | return !(imdbIdError || titleError || directorError || yearError) 138 | } 139 | 140 | const getContent = () => { 141 | let stepContent = null 142 | if (step === 1) { 143 | stepContent = ( 144 | 153 | ) 154 | } else if (step === 2) { 155 | stepContent = ( 156 | 168 | ) 169 | } else if (step === 3) { 170 | const movie = { imdbId, title, director, year, poster } 171 | stepContent = ( 172 | 173 | ) 174 | } 175 | 176 | return ( 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | Search 185 | Search movie 186 | 187 | 188 | 189 | 190 | 191 | 192 | Movie 193 | Movie Form 194 | 195 | 196 | 197 | 198 | 199 | 200 | Complete 201 | Preview and complete 202 | 203 | 204 | 205 | 206 | 207 | 210 | 211 | 215 | 216 | 217 | {step === 3 && ( 218 | <> 219 | 220 | 221 | 222 | )} 223 | 224 | 225 | {stepContent} 226 | 227 | 228 | 229 | ) 230 | } 231 | 232 | 233 | return keycloak && keycloak.authenticated && isAdmin(keycloak) ? getContent() : 234 | } 235 | 236 | export default MovieWizard -------------------------------------------------------------------------------- /movies-ui/src/components/wizard/SearchStep.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Form, Segment, Table } from 'semantic-ui-react' 3 | 4 | function SearchStep({ searchText, isLoading, movies, selectedMovie, handleChange, handleSearchMovies, handleTableSelection }) { 5 | const movieList = movies ? movies.map(movie => { 6 | const active = movie && selectedMovie && movie.imdbId === selectedMovie.imdbId 7 | return ( 8 | handleTableSelection(movie)}> 9 | {movie.imdbId} 10 | {movie.title} 11 | {movie.director} 12 | {movie.year} 13 | 14 | ) 15 | }) : ( 16 | 17 | 18 | ) 19 | 20 | return ( 21 | 22 |
23 | 24 | 32 | 39 | 40 |
41 | 42 | 43 | 44 | 45 | ImdbID 46 | Title 47 | Director 48 | Year 49 | 50 | 51 | 52 | 53 | {movieList} 54 | 55 |
56 |
57 | ) 58 | } 59 | 60 | export default SearchStep -------------------------------------------------------------------------------- /movies-ui/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | 15 | .ui.table.selectable tr { 16 | cursor: pointer 17 | } 18 | -------------------------------------------------------------------------------- /movies-ui/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | // 10 | 11 | // 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /movies-ui/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /movies-ui/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /scripts/my-functions.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | TIMEOUT=120 4 | 5 | # -- wait_for_container_log -- 6 | # $1: docker container name 7 | # S2: spring value to wait to appear in container logs 8 | function wait_for_container_log() { 9 | local log_waiting="Waiting for string '$2' in the $1 logs ..." 10 | echo "${log_waiting} It will timeout in ${TIMEOUT}s" 11 | SECONDS=0 12 | 13 | while true ; do 14 | local log=$(docker logs $1 2>&1 | grep "$2") 15 | if [ -n "$log" ] ; then 16 | echo $log 17 | break 18 | fi 19 | 20 | if [ $SECONDS -ge $TIMEOUT ] ; then 21 | echo "${log_waiting} TIMEOUT" 22 | break; 23 | fi 24 | sleep 1 25 | done 26 | } -------------------------------------------------------------------------------- /shutdown-environment.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo 4 | echo "Starting the environment shutdown" 5 | echo "=================================" 6 | 7 | echo 8 | echo "Removing containers" 9 | echo "-------------------" 10 | docker rm -fv mongodb keycloak postgres 11 | 12 | echo 13 | echo "Removing network" 14 | echo "----------------" 15 | docker network rm springboot-react-keycloak-net 16 | 17 | echo 18 | echo "Environment shutdown successfully" 19 | echo "=================================" 20 | echo --------------------------------------------------------------------------------