├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── README.md ├── book-service ├── build.gradle ├── settings.gradle └── src │ ├── integration-test │ └── java │ │ └── com │ │ └── ivanfranchin │ │ └── bookservice │ │ ├── AbstractTestcontainers.java │ │ └── BookServiceApplicationTests.java │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── bookservice │ │ │ ├── BookServiceApplication.java │ │ │ ├── book │ │ │ ├── BookController.java │ │ │ ├── BookRepository.java │ │ │ ├── BookService.java │ │ │ ├── dto │ │ │ │ ├── BookResponse.java │ │ │ │ ├── CreateBookRequest.java │ │ │ │ └── UpdateBookRequest.java │ │ │ ├── exception │ │ │ │ └── BookNotFoundException.java │ │ │ └── model │ │ │ │ └── Book.java │ │ │ ├── config │ │ │ ├── ErrorAttributesConfig.java │ │ │ └── SwaggerConfig.java │ │ │ └── security │ │ │ ├── JwtAuthConverter.java │ │ │ ├── JwtAuthConverterProperties.java │ │ │ └── SecurityConfig.java │ └── resources │ │ ├── application.yml │ │ └── banner.txt │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── bookservice │ ├── controller │ └── BookControllerTest.java │ ├── dto │ ├── BookResponseTest.java │ ├── CreateBookRequestTest.java │ └── UpdateBookRequestTest.java │ ├── repository │ └── BookRepositoryTest.java │ └── service │ └── BookServiceTest.java ├── build-docker-images.sh ├── build.gradle ├── documentation └── book-service-swagger.jpeg ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── init-environment.sh ├── init-keycloak.sh ├── remove-docker-images.sh ├── scripts └── my-functions.sh ├── settings.gradle └── shutdown-environment.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | /gradlew text eol=lf 2 | *.bat text eol=crlf 3 | *.jar binary 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ivangfr 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/** 5 | !**/src/test/** 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | out/ 22 | 23 | ### NetBeans ### 24 | /nbproject/private/ 25 | /nbbuild/ 26 | /dist/ 27 | /nbdist/ 28 | /.nb-gradle/ 29 | 30 | ### VS Code ### 31 | .vscode/ 32 | 33 | ### MAC OS ### 34 | *.DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # springboot-keycloak-mongodb-testcontainers 2 | 3 | The goals of this project are: 4 | 5 | - Create a [`Spring Boot`](https://docs.spring.io/spring-boot/index.html) application that manages books, called `book-service`; 6 | - Use [`Keycloak`](https://www.keycloak.org) as OpenID Connect provider; 7 | - Test using [`Testcontainers`](https://testcontainers.com/); 8 | - Explore the utilities and annotations that `Spring Boot` provides for testing applications. 9 | 10 | ## Proof-of-Concepts & Articles 11 | 12 | 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. 13 | 14 | ## Additional Readings 15 | 16 | - \[**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) 17 | - \[**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) 18 | - \[**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) 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 | ## Application 22 | 23 | - ### book-service 24 | 25 | `Spring Boot` Web application that manages books. [`MongoDB`](https://www.mongodb.com) is used as storage, and the application's sensitive endpoints (like create, update and delete books) are secured. 26 | 27 | ![book-service-swagger](documentation/book-service-swagger.jpeg) 28 | 29 | ## Prerequisites 30 | 31 | - [`Java 21`](https://www.oracle.com/java/technologies/downloads/#java21) or higher; 32 | - A containerization tool (e.g., [`Docker`](https://www.docker.com), [`Podman`](https://podman.io), etc.) 33 | - [`jq`](https://jqlang.github.io/jq/) 34 | 35 | ## Start Environment 36 | 37 | Open a terminal and, inside the `springboot-keycloak-mongodb-testcontainers` root folder, run the script below 38 | ```bash 39 | ./init-environment.sh 40 | ``` 41 | 42 | ## Configure Keycloak 43 | 44 | There are two ways: running a script or using the `Keycloak` website 45 | 46 | ### Running Script 47 | 48 | - In a terminal, make sure you are inside the `springboot-keycloak-mongodb-testcontainers` root folder 49 | 50 | - Run the following script to configure `Keycloak` for `book-service` application 51 | ```bash 52 | ./init-keycloak.sh 53 | ``` 54 | 55 | This script will create: 56 | - `company-services` realm; 57 | - `book-service` client; 58 | - `manage_books` client role; 59 | - user with _username_ `ivan.franchin` and _password_ `123` and with the role `manage_books` assigned. 60 | 61 | - The `book-service` client secret (`BOOK_SERVICE_CLIENT_SECRET`) is shown at the end of the execution. It will be used in the next step. 62 | 63 | - You can check the configuration in `Keycloak` by accessing http://localhost:8080. The credentials are `admin/admin`. 64 | 65 | ### Using Keycloak Website 66 | 67 | #### Login 68 | 69 | - Access http://localhost:8080 70 | 71 | - Login with the credentials 72 | ```text 73 | Username: admin 74 | Password: admin 75 | ``` 76 | 77 | #### Create a new Realm 78 | 79 | - On the left menu, click the dropdown button that contains `Keycloak` and then, click `Create Realm` button 80 | - Set `company-services` to the `Realm name` field and click `Create` button 81 | 82 | ### Disable Required Action Verify Profile 83 | 84 | - On the left menu, click `Authentication` 85 | - Select `Required actions` tab 86 | - Disable `Verify Profile` 87 | 88 | #### Create a new Client 89 | 90 | - On the left menu, click `Clients` 91 | - Click `Create client` button 92 | - In `General Settings` 93 | - Set `book-service` to `Client ID` 94 | - Click `Next` button 95 | - In `Capability config` 96 | - Enable `Client authentication` toggle switch 97 | - Click `Next` button 98 | - In `Login settings` tab 99 | - Set `http://localhost:9080/*` to `Valid redirect URIs` 100 | - Click `Save` button 101 | - In `Credentials` tab, you can find the secret generated for `book-service` 102 | - In `Roles` tab 103 | - Click `Create role` button 104 | - Set `manage_books` to `Role Name` 105 | - Click `Save` button 106 | 107 | #### Create a new User 108 | 109 | - On the left menu, click `Users` 110 | - Click `Create new user` button 111 | - Set `ivan.franchin` to `Username` field 112 | - Click `Create` 113 | - In `Credentials` tab 114 | - Click `Set password` button 115 | - Set the value `123` to `Password` and `Password confirmation` 116 | - Disable the `Temporary` toggle switch 117 | - Click `Save` button 118 | - Confirm by clicking `Save password` button 119 | - In `Role Mappings` tab 120 | - Click `Assign role` button 121 | - Click `Filter by realm roles` dropdown button and select `Filter by clients` 122 | - Select `[book-service] manage_books` name and click `Assign` button 123 | 124 | ## Running book-service with Gradle 125 | 126 | - Open a new terminal and navigate to the `springboot-keycloak-mongodb-testcontainers` root folder 127 | 128 | - Run the following command to start the application 129 | ```bash 130 | ./gradlew book-service:clean book-service:bootRun --args='--server.port=9080' 131 | ``` 132 | 133 | ## Running book-service as a Docker Container 134 | 135 | - In a terminal, navigate to the `springboot-keycloak-mongodb-testcontainers` root folder 136 | 137 | - Build Docker Image 138 | ```bash 139 | ./build-docker-images.sh 140 | ``` 141 | | Environment Variable | Description | 142 | |----------------------|-------------------------------------------------------------------| 143 | | `MONGODB_HOST` | Specify host of the `Mongo` database to use (default `localhost`) | 144 | | `MONGODB_PORT` | Specify port of the `Mongo` database to use (default `27017`) | 145 | | `KEYCLOAK_HOST` | Specify host of the `Keycloak` to use (default `localhost`) | 146 | | `KEYCLOAK_PORT` | Specify port of the `Keycloak` to use (default `8080`) | 147 | 148 | - Run `book-service` docker container, joining it to project Docker network 149 | ```bash 150 | docker run --rm --name book-service \ 151 | -p 9080:8080 \ 152 | -e MONGODB_HOST=mongodb \ 153 | -e KEYCLOAK_HOST=keycloak \ 154 | --network=springboot-keycloak-mongodb-testcontainers-net \ 155 | ivanfranchin/book-service:1.0.0 156 | ``` 157 | 158 | ## Getting Access Token 159 | 160 | - In a terminal, create an environment variable that contains the `Client Secret` generated by `Keycloak` to `book-service` at [Configure Keycloak](#configure-keycloak) step 161 | ```bash 162 | BOOK_SERVICE_CLIENT_SECRET=... 163 | ``` 164 | 165 | - **When running book-service with Gradle** 166 | 167 | Run the commands below to get an access token for `ivan.franchin` 168 | ```bash 169 | ACCESS_TOKEN=$(curl -s -X POST \ 170 | "http://localhost:8080/realms/company-services/protocol/openid-connect/token" \ 171 | -H "Content-Type: application/x-www-form-urlencoded" \ 172 | -d "username=ivan.franchin" \ 173 | -d "password=123" \ 174 | -d "grant_type=password" \ 175 | -d "client_secret=$BOOK_SERVICE_CLIENT_SECRET" \ 176 | -d "client_id=book-service" | jq -r .access_token) 177 | echo $ACCESS_TOKEN 178 | ``` 179 | 180 | - **When running book-service as a Docker Container** 181 | 182 | Run the commands below to get an access token for `ivan.franchin` 183 | ```bash 184 | ACCESS_TOKEN=$( 185 | docker run -t --rm -e CLIENT_SECRET=$BOOK_SERVICE_CLIENT_SECRET --network springboot-keycloak-mongodb-testcontainers-net alpine/curl:latest sh -c ' 186 | curl -s -X POST http://keycloak:8080/realms/company-services/protocol/openid-connect/token \ 187 | -H "Content-Type: application/x-www-form-urlencoded" \ 188 | -d "username=ivan.franchin" \ 189 | -d "password=123" \ 190 | -d "grant_type=password" \ 191 | -d "client_secret=$CLIENT_SECRET" \ 192 | -d "client_id=book-service"' | jq -r .access_token) 193 | echo $ACCESS_TOKEN 194 | ``` 195 | > **Note**: We are running a alpine/curl Docker container and joining it to the project Docker network. By specifying `"keycloak:8080"` as host/port, we won't encounter the error related to an invalid token issuer. 196 | 197 | - In [jwt.io](https://jwt.io), you can decode and verify the `JWT` access token 198 | 199 | ## Test using cURL 200 | 201 | - In terminal, call the endpoint `GET /api/books` 202 | ```bash 203 | curl -i http://localhost:9080/api/books 204 | ``` 205 | It should return: 206 | ```text 207 | HTTP/1.1 200 208 | [] 209 | ``` 210 | 211 | - Try to call the endpoint `POST /api/books`, without access token 212 | ```bash 213 | curl -i -X POST http://localhost:9080/api/books \ 214 | -H "Content-Type: application/json" \ 215 | -d '{"authorName": "Ivan Franchin", "title": "Java 8", "price": 10.5}' 216 | ``` 217 | It should return: 218 | ```text 219 | HTTP/1.1 401 220 | ``` 221 | 222 | - Get the Access Token as explained on section [Getting Access Token](#getting-access-token) 223 | 224 | - Call the endpoint `POST /api/books`, now informing the access token 225 | ```bash 226 | curl -i -X POST http://localhost:9080/api/books \ 227 | -H "Authorization: Bearer $ACCESS_TOKEN" \ 228 | -H "Content-Type: application/json" \ 229 | -d '{"authorName": "Ivan Franchin", "title": "Java 8", "price": 10.5}' 230 | ``` 231 | It should return something like: 232 | ```text 233 | HTTP/1.1 201 234 | {"id":"612f4f9438e39e473c4d098b", "authorName":"Ivan Franchin", "title":"Java 8", "price":10.5} 235 | ``` 236 | 237 | ## Test using Swagger 238 | 239 | - Access http://localhost:9080/swagger-ui.html 240 | 241 | - Click `GET /api/books` to open it. Then, click `Try it out` button and, finally, click `Execute` button. 242 | 243 | It will return a HTTP status code `200` and an empty list or a list with some books if you've already added them. 244 | 245 | - Now, let's try to call a secured endpoint without authentication. Click `POST /api/books` to open it. Then, click `Try it out` button (you can use the default values) and, finally, click `Execute` button. 246 | 247 | It will return: 248 | ```text 249 | Code: 401 250 | Details: Error: response status is 401 251 | ``` 252 | 253 | - Get the Access Token as explained on section [Getting Access Token](#getting-access-token) 254 | 255 | - Copy the token generated and go back to `Swagger` 256 | 257 | - Click the `Authorize` button and paste the access token in the `Value` field. Then, click `Authorize` and, to finalize, click `Close` 258 | 259 | - Go to `POST /api/books`, click `Try it out` and, finally, click `Execute` button. 260 | 261 | It should return something like: 262 | ```text 263 | HTTP/1.1 201 264 | { 265 | "id": "612f502f38e39e473c4d098c", 266 | "authorName": "Ivan Franchin", 267 | "title": "SpringBoot", 268 | "price": 10.5 269 | } 270 | ``` 271 | 272 | ## Useful Links & Commands 273 | 274 | - **MongoDB** 275 | 276 | List books 277 | ```bash 278 | docker exec -it mongodb mongosh bookdb 279 | db.books.find() 280 | ``` 281 | > Type `exit` to get out of MongoDB shell 282 | 283 | ## Shutdown 284 | 285 | - To stop `book-service`, go to the terminal where the application is running and press `Ctrl+C`; 286 | - To stop the Docker containers started using the `./init-environment.sh` script, make sure you are in `springboot-keycloak-mongodb-testcontainers` and run the script below: 287 | ```bash 288 | ./shutdown-environment.sh 289 | ``` 290 | 291 | ## Cleanup 292 | 293 | To remove the Docker image created by this project, go to a terminal and, inside the `springboot-keycloak-mongodb-testcontainers` root folder, run the following script: 294 | ```bash 295 | ./remove-docker-images.sh 296 | ``` 297 | 298 | ## Running Unit and Integration Tests 299 | 300 | - In a terminal and inside the `springboot-keycloak-mongodb-testcontainers` root folder, run the command below to run unit and integration tests 301 | ```bash 302 | ./gradlew book-service:clean book-service:assemble \ 303 | book-service:cleanTest \ 304 | book-service:test \ 305 | book-service:integrationTest 306 | ``` 307 | > **Note**: During integration tests, `Testcontainers` will automatically start `MongoDB` and `Keycloak` containers before the tests begin and shut them down when the tests finish. 308 | 309 | - From the `springboot-keycloak-mongodb-testcontainers` root folder, the **Unit Testing Report** can be found at: 310 | ```text 311 | book-service/build/reports/tests/test/index.html 312 | ``` 313 | 314 | - From the `springboot-keycloak-mongodb-testcontainers` root folder, the **Integration Testing Report** can be found at: 315 | ```text 316 | book-service/build/reports/tests/integrationTest/index.html 317 | ``` 318 | -------------------------------------------------------------------------------- /book-service/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java' 3 | id 'org.springframework.boot' version '3.4.5' 4 | id 'io.spring.dependency-management' version '1.1.7' 5 | } 6 | 7 | group = 'com.ivanfranchin' 8 | version = '1.0.0' 9 | 10 | java { 11 | toolchain { 12 | languageVersion = JavaLanguageVersion.of(21) 13 | } 14 | } 15 | 16 | configurations { 17 | compileOnly { 18 | extendsFrom annotationProcessor 19 | } 20 | integrationTestImplementation { 21 | extendsFrom testImplementation 22 | } 23 | } 24 | 25 | repositories { 26 | mavenCentral() 27 | } 28 | 29 | ext { 30 | set('springdocOpenApiVersion', '2.8.6') 31 | set('keycloakVersion', '26.0.5') 32 | set('httpClient5Version', '5.4.1') 33 | } 34 | 35 | // adding integration test 36 | 37 | sourceSets { 38 | integrationTest { 39 | java { 40 | compileClasspath += main.output + test.output 41 | runtimeClasspath += main.output + test.output 42 | srcDir file('src/integration-test/java') 43 | } 44 | // resources.srcDir file('src/integration-test/resources') 45 | } 46 | } 47 | 48 | idea { 49 | module { 50 | testSourceDirs += project.sourceSets.integrationTest.java.srcDirs 51 | // testSourceDirs += project.sourceSets.integrationTest.resources.srcDirs 52 | } 53 | } 54 | 55 | task integrationTest(type: Test) { 56 | group 'springboot-testing' 57 | description 'Runs the integration tests' 58 | 59 | testClassesDirs = sourceSets.integrationTest.output.classesDirs 60 | classpath = sourceSets.integrationTest.runtimeClasspath 61 | 62 | useJUnitPlatform() 63 | } 64 | 65 | dependencies { 66 | implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' 67 | implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' 68 | implementation 'org.springframework.boot:spring-boot-starter-validation' 69 | implementation 'org.springframework.boot:spring-boot-starter-web' 70 | 71 | implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocOpenApiVersion}" 72 | 73 | testImplementation "org.keycloak:keycloak-admin-client:${keycloakVersion}" 74 | 75 | // this dependency is needed because keycloak-admin-client dependency is still using httpclient4 76 | // See: https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide#apache-httpclient-in-resttemplate 77 | implementation "org.apache.httpcomponents.client5:httpclient5:${httpClient5Version}" 78 | 79 | annotationProcessor 'org.projectlombok:lombok' 80 | compileOnly 'org.projectlombok:lombok' 81 | 82 | testImplementation 'org.testcontainers:junit-jupiter' 83 | testImplementation 'org.testcontainers:mongodb' 84 | 85 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 86 | testImplementation 'org.springframework.boot:spring-boot-testcontainers' 87 | testImplementation 'org.springframework.security:spring-security-test' 88 | 89 | integrationTestAnnotationProcessor 'org.projectlombok:lombok' 90 | integrationTestCompileOnly 'org.projectlombok:lombok' 91 | } 92 | 93 | tasks.named('test') { 94 | useJUnitPlatform() 95 | } 96 | 97 | check.dependsOn integrationTest 98 | integrationTest.mustRunAfter test 99 | -------------------------------------------------------------------------------- /book-service/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'book-service' 2 | -------------------------------------------------------------------------------- /book-service/src/integration-test/java/com/ivanfranchin/bookservice/AbstractTestcontainers.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice; 2 | 3 | import org.keycloak.admin.client.Keycloak; 4 | import org.keycloak.admin.client.KeycloakBuilder; 5 | import org.keycloak.representations.idm.ClientRepresentation; 6 | import org.keycloak.representations.idm.CredentialRepresentation; 7 | import org.keycloak.representations.idm.RealmRepresentation; 8 | import org.keycloak.representations.idm.UserRepresentation; 9 | import org.springframework.boot.testcontainers.service.connection.ServiceConnection; 10 | import org.springframework.test.context.DynamicPropertyRegistry; 11 | import org.springframework.test.context.DynamicPropertySource; 12 | import org.testcontainers.containers.GenericContainer; 13 | import org.testcontainers.containers.MongoDBContainer; 14 | import org.testcontainers.containers.wait.strategy.Wait; 15 | import org.testcontainers.junit.jupiter.Container; 16 | import org.testcontainers.junit.jupiter.Testcontainers; 17 | 18 | import java.time.Duration; 19 | import java.util.Collections; 20 | import java.util.HashMap; 21 | import java.util.List; 22 | import java.util.Map; 23 | 24 | @Testcontainers 25 | public abstract class AbstractTestcontainers { 26 | 27 | @Container 28 | @ServiceConnection 29 | private static final MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:8.0.6"); 30 | 31 | private static final GenericContainer keycloakContainer = new GenericContainer<>("quay.io/keycloak/keycloak:26.2.1"); 32 | 33 | protected static Keycloak keycloakBookService; 34 | 35 | @DynamicPropertySource 36 | private static void dynamicProperties(DynamicPropertyRegistry registry) { 37 | keycloakContainer.withExposedPorts(8080) 38 | .withEnv("KC_BOOTSTRAP_ADMIN_USERNAME", "admin") 39 | .withEnv("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin") 40 | .withEnv("KC_DB", "dev-mem") 41 | .withCommand("start-dev") 42 | .waitingFor(Wait.forHttp("/admin").forPort(8080).withStartupTimeout(Duration.ofMinutes(2))) 43 | .start(); 44 | 45 | registry.add("spring.data.mongodb.host", mongoDBContainer::getHost); 46 | registry.add("spring.data.mongodb.port", () -> mongoDBContainer.getMappedPort(27017)); 47 | 48 | String keycloakHost = keycloakContainer.getHost(); 49 | Integer keycloakPort = keycloakContainer.getMappedPort(8080); 50 | 51 | String issuerUri = String.format("http://%s:%s/realms/company-services", keycloakHost, keycloakPort); 52 | registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri", () -> issuerUri); 53 | 54 | if (keycloakBookService == null) { 55 | String keycloakServerUrl = String.format("http://%s:%s", keycloakHost, keycloakPort); 56 | setupKeycloak(keycloakServerUrl); 57 | } 58 | } 59 | 60 | private static void setupKeycloak(String keycloakServerUrl) { 61 | Keycloak keycloakAdmin = KeycloakBuilder.builder() 62 | .serverUrl(keycloakServerUrl) 63 | .realm("master") 64 | .username("admin") 65 | .password("admin") 66 | .clientId("admin-cli") 67 | .build(); 68 | 69 | // Realm 70 | RealmRepresentation realmRepresentation = new RealmRepresentation(); 71 | realmRepresentation.setRealm(COMPANY_SERVICE_REALM_NAME); 72 | realmRepresentation.setEnabled(true); 73 | realmRepresentation.setRequiredActions(List.of()); 74 | 75 | // Client 76 | ClientRepresentation clientRepresentation = new ClientRepresentation(); 77 | clientRepresentation.setId(BOOK_SERVICE_CLIENT_ID); 78 | clientRepresentation.setDirectAccessGrantsEnabled(true); 79 | clientRepresentation.setSecret(BOOK_SERVICE_CLIENT_SECRET); 80 | realmRepresentation.setClients(Collections.singletonList(clientRepresentation)); 81 | 82 | // Client roles 83 | Map> clientRoles = new HashMap<>(); 84 | clientRoles.put(BOOK_SERVICE_CLIENT_ID, BOOK_SERVICE_ROLES); 85 | 86 | // Credentials 87 | CredentialRepresentation credentialRepresentation = new CredentialRepresentation(); 88 | credentialRepresentation.setType(CredentialRepresentation.PASSWORD); 89 | credentialRepresentation.setValue(USER_PASSWORD); 90 | 91 | // User 92 | UserRepresentation userRepresentation = new UserRepresentation(); 93 | userRepresentation.setUsername(USER_USERNAME); 94 | userRepresentation.setEnabled(true); 95 | userRepresentation.setCredentials(Collections.singletonList(credentialRepresentation)); 96 | userRepresentation.setClientRoles(clientRoles); 97 | realmRepresentation.setUsers(Collections.singletonList(userRepresentation)); 98 | 99 | keycloakAdmin.realms().create(realmRepresentation); 100 | 101 | keycloakBookService = KeycloakBuilder.builder() 102 | .serverUrl(keycloakServerUrl) 103 | .realm(COMPANY_SERVICE_REALM_NAME) 104 | .username(USER_USERNAME) 105 | .password(USER_PASSWORD) 106 | .clientId(BOOK_SERVICE_CLIENT_ID) 107 | .clientSecret(BOOK_SERVICE_CLIENT_SECRET) 108 | .build(); 109 | } 110 | 111 | private static final String COMPANY_SERVICE_REALM_NAME = "company-services"; 112 | private static final String BOOK_SERVICE_CLIENT_ID = "book-service"; 113 | private static final String BOOK_SERVICE_CLIENT_SECRET = "abc123"; 114 | private static final List BOOK_SERVICE_ROLES = Collections.singletonList("manage_books"); 115 | private static final String USER_USERNAME = "ivan.franchin"; 116 | private static final String USER_PASSWORD = "123"; 117 | } 118 | -------------------------------------------------------------------------------- /book-service/src/integration-test/java/com/ivanfranchin/bookservice/BookServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice; 2 | 3 | import com.ivanfranchin.bookservice.book.BookRepository; 4 | import com.ivanfranchin.bookservice.book.dto.BookResponse; 5 | import com.ivanfranchin.bookservice.book.dto.CreateBookRequest; 6 | import com.ivanfranchin.bookservice.book.dto.UpdateBookRequest; 7 | import com.ivanfranchin.bookservice.book.model.Book; 8 | import org.junit.jupiter.api.BeforeEach; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; 13 | import org.springframework.boot.test.web.client.TestRestTemplate; 14 | import org.springframework.http.HttpEntity; 15 | import org.springframework.http.HttpHeaders; 16 | import org.springframework.http.HttpMethod; 17 | import org.springframework.http.HttpStatus; 18 | import org.springframework.http.ResponseEntity; 19 | 20 | import java.math.BigDecimal; 21 | import java.util.List; 22 | import java.util.Optional; 23 | 24 | import static org.assertj.core.api.Assertions.assertThat; 25 | 26 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 27 | class BookServiceApplicationTests extends AbstractTestcontainers { 28 | 29 | @Autowired 30 | private BookRepository bookRepository; 31 | 32 | @Autowired 33 | private TestRestTemplate testRestTemplate; 34 | 35 | @BeforeEach 36 | void setUp() { 37 | bookRepository.deleteAll(); 38 | } 39 | 40 | @Test 41 | void testGetBooksWhenThereIsNone() { 42 | ResponseEntity responseEntity = testRestTemplate.getForEntity(API_BOOKS_URL, BookResponse[].class); 43 | 44 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); 45 | assertThat(responseEntity.getBody()).isEmpty(); 46 | } 47 | 48 | @Test 49 | void testGetBooksWhenThereIsOne() { 50 | Book book = bookRepository.save(getDefaultBook()); 51 | 52 | ResponseEntity responseEntity = testRestTemplate.getForEntity(API_BOOKS_URL, BookResponse[].class); 53 | 54 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); 55 | assertThat(responseEntity.getBody()).hasSize(1); 56 | assertThat(responseEntity.getBody()).isNotNull(); 57 | assertThat(responseEntity.getBody()[0].id()).isEqualTo(book.getId()); 58 | assertThat(responseEntity.getBody()[0].authorName()).isEqualTo(book.getAuthorName()); 59 | assertThat(responseEntity.getBody()[0].title()).isEqualTo(book.getTitle()); 60 | assertThat(responseEntity.getBody()[0].price()).isEqualTo(book.getPrice()); 61 | } 62 | 63 | @Test 64 | void testCreateBookWithoutAuthentication() { 65 | CreateBookRequest createBookRequest = getDefaultCreateBookRequest(); 66 | ResponseEntity responseEntity = testRestTemplate.postForEntity(API_BOOKS_URL, createBookRequest, String.class); 67 | 68 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); 69 | assertThat(responseEntity.getBody()).isNull(); 70 | } 71 | 72 | @Test 73 | void testCreateBookInformingInvalidToken() { 74 | CreateBookRequest createBookRequest = getDefaultCreateBookRequest(); 75 | 76 | HttpHeaders headers = authBearerHeaders("abcdef"); 77 | ResponseEntity responseEntity = testRestTemplate.postForEntity(API_BOOKS_URL, new HttpEntity<>(createBookRequest, headers), String.class); 78 | 79 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); 80 | assertThat(responseEntity.getBody()).isNull(); 81 | } 82 | 83 | @Test 84 | void testCreateBookInformingValidToken() { 85 | CreateBookRequest createBookRequest = getDefaultCreateBookRequest(); 86 | 87 | String accessToken = keycloakBookService.tokenManager().grantToken().getToken(); 88 | 89 | HttpHeaders headers = authBearerHeaders(accessToken); 90 | ResponseEntity responseEntity = testRestTemplate.postForEntity(API_BOOKS_URL, new HttpEntity<>(createBookRequest, headers), BookResponse.class); 91 | 92 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.CREATED); 93 | assertThat(responseEntity.getBody()).isNotNull(); 94 | assertThat(responseEntity.getBody().id()).isNotNull(); 95 | assertThat(responseEntity.getBody().authorName()).isEqualTo(createBookRequest.authorName()); 96 | assertThat(responseEntity.getBody().title()).isEqualTo(createBookRequest.title()); 97 | assertThat(responseEntity.getBody().price()).isEqualTo(createBookRequest.price()); 98 | 99 | Optional bookOptional = bookRepository.findById(responseEntity.getBody().id()); 100 | assertThat(bookOptional.isPresent()).isTrue(); 101 | bookOptional.ifPresent(bookCreated -> { 102 | assertThat(bookCreated.getAuthorName()).isEqualTo(createBookRequest.authorName()); 103 | assertThat(bookCreated.getTitle()).isEqualTo(createBookRequest.title()); 104 | assertThat(bookCreated.getPrice()).isEqualTo(createBookRequest.price()); 105 | }); 106 | } 107 | 108 | @Test 109 | void testUpdateBookWhenNonExistent() { 110 | UpdateBookRequest updateBookRequest = new UpdateBookRequest(null, "SpringBoot 2", null); 111 | 112 | String accessToken = keycloakBookService.tokenManager().grantToken().getToken(); 113 | HttpHeaders headers = authBearerHeaders(accessToken); 114 | 115 | String url = String.format(API_BOOKS_ID_URL, "123"); 116 | ResponseEntity responseEntity = testRestTemplate.exchange(url, HttpMethod.PATCH, 117 | new HttpEntity<>(updateBookRequest, headers), MessageError.class); 118 | 119 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); 120 | assertThat(responseEntity.getBody()).isNotNull(); 121 | assertThat(responseEntity.getBody().timestamp()).isNotEmpty(); 122 | assertThat(responseEntity.getBody().status()).isEqualTo(404); 123 | assertThat(responseEntity.getBody().error()).isEqualTo(ERROR_NOT_FOUND); 124 | assertThat(responseEntity.getBody().message()).isEqualTo("Book with id '123' not found."); 125 | assertThat(responseEntity.getBody().path()).isEqualTo(url); 126 | assertThat(responseEntity.getBody().errors()).isNull(); 127 | } 128 | 129 | @Test 130 | void testUpdateBookWhenExistent() { 131 | Book book = bookRepository.save(getDefaultBook()); 132 | UpdateBookRequest updateBookRequest = new UpdateBookRequest("Ivan Franchin 2", "Java 9", null); 133 | 134 | String accessToken = keycloakBookService.tokenManager().grantToken().getToken(); 135 | HttpHeaders headers = authBearerHeaders(accessToken); 136 | 137 | String url = String.format(API_BOOKS_ID_URL, book.getId()); 138 | ResponseEntity responseEntity = testRestTemplate.exchange(url, HttpMethod.PATCH, 139 | new HttpEntity<>(updateBookRequest, headers), BookResponse.class); 140 | 141 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); 142 | assertThat(responseEntity.getBody()).isNotNull(); 143 | assertThat(responseEntity.getBody().id()).isNotNull(); 144 | assertThat(responseEntity.getBody().authorName()).isEqualTo(updateBookRequest.authorName()); 145 | assertThat(responseEntity.getBody().title()).isEqualTo(updateBookRequest.title()); 146 | assertThat(responseEntity.getBody().price()).isEqualTo(book.getPrice()); 147 | 148 | Optional bookOptional = bookRepository.findById(responseEntity.getBody().id()); 149 | assertThat(bookOptional.isPresent()).isTrue(); 150 | bookOptional.ifPresent(bookUpdated -> { 151 | assertThat(bookUpdated.getAuthorName()).isEqualTo(updateBookRequest.authorName()); 152 | assertThat(bookUpdated.getTitle()).isEqualTo(updateBookRequest.title()); 153 | assertThat(bookUpdated.getPrice()).isEqualTo(book.getPrice()); 154 | }); 155 | } 156 | 157 | @Test 158 | void testDeleteBookWhenNonExistent() { 159 | String accessToken = keycloakBookService.tokenManager().grantToken().getToken(); 160 | 161 | HttpHeaders headers = authBearerHeaders(accessToken); 162 | 163 | String url = String.format(API_BOOKS_ID_URL, "123"); 164 | ResponseEntity responseEntity = testRestTemplate.exchange(url, HttpMethod.DELETE, 165 | new HttpEntity<>(headers), MessageError.class); 166 | 167 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); 168 | assertThat(responseEntity.getBody()).isNotNull(); 169 | assertThat(responseEntity.getBody().timestamp()).isNotEmpty(); 170 | assertThat(responseEntity.getBody().status()).isEqualTo(404); 171 | assertThat(responseEntity.getBody().error()).isEqualTo(ERROR_NOT_FOUND); 172 | assertThat(responseEntity.getBody().message()).isEqualTo("Book with id '123' not found."); 173 | assertThat(responseEntity.getBody().path()).isEqualTo(url); 174 | assertThat(responseEntity.getBody().errors()).isNull(); 175 | } 176 | 177 | @Test 178 | void testDeleteBookWhenExistent() { 179 | Book book = bookRepository.save(getDefaultBook()); 180 | 181 | String accessToken = keycloakBookService.tokenManager().grantToken().getToken(); 182 | HttpHeaders headers = authBearerHeaders(accessToken); 183 | 184 | String url = String.format(API_BOOKS_ID_URL, book.getId()); 185 | ResponseEntity responseEntity = testRestTemplate.exchange(url, HttpMethod.DELETE, 186 | new HttpEntity<>(headers), BookResponse.class); 187 | 188 | assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); 189 | assertThat(responseEntity.getBody()).isNotNull(); 190 | assertThat(responseEntity.getBody().id()).isNotNull(); 191 | assertThat(responseEntity.getBody().authorName()).isEqualTo(book.getAuthorName()); 192 | assertThat(responseEntity.getBody().title()).isEqualTo(book.getTitle()); 193 | assertThat(responseEntity.getBody().price()).isEqualTo(book.getPrice()); 194 | 195 | Optional bookOptional = bookRepository.findById(responseEntity.getBody().id()); 196 | assertThat(bookOptional.isPresent()).isFalse(); 197 | } 198 | 199 | private HttpHeaders authBearerHeaders(String accessToken) { 200 | HttpHeaders headers = new HttpHeaders(); 201 | headers.set("Authorization", "Bearer " + accessToken); 202 | return headers; 203 | } 204 | 205 | private Book getDefaultBook() { 206 | return new Book("Ivan Franchin", "SpringBoot", BigDecimal.valueOf(29.99)); 207 | } 208 | 209 | private CreateBookRequest getDefaultCreateBookRequest() { 210 | return new CreateBookRequest("Ivan Franchin", "SpringBoot", BigDecimal.valueOf(10.99)); 211 | } 212 | 213 | private record MessageError( 214 | String timestamp, 215 | int status, 216 | String error, 217 | String message, 218 | String path, 219 | String errorCode, List errors) { 220 | 221 | public record ErrorDetail( 222 | List codes, 223 | String defaultMessage, 224 | String objectName, 225 | String field, 226 | String rejectedValue, 227 | boolean bindingFailure, 228 | String code) { 229 | } 230 | } 231 | 232 | private static final String API_BOOKS_URL = "/api/books"; 233 | private static final String API_BOOKS_ID_URL = "/api/books/%s"; 234 | private static final String ERROR_NOT_FOUND = "Not Found"; 235 | } 236 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/BookServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class BookServiceApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(BookServiceApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/book/BookController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.book; 2 | 3 | import com.ivanfranchin.bookservice.book.dto.BookResponse; 4 | import com.ivanfranchin.bookservice.book.dto.CreateBookRequest; 5 | import com.ivanfranchin.bookservice.book.dto.UpdateBookRequest; 6 | import com.ivanfranchin.bookservice.book.model.Book; 7 | import io.swagger.v3.oas.annotations.Operation; 8 | import io.swagger.v3.oas.annotations.security.SecurityRequirement; 9 | import jakarta.validation.Valid; 10 | import lombok.RequiredArgsConstructor; 11 | import lombok.extern.slf4j.Slf4j; 12 | import org.springframework.http.HttpStatus; 13 | import org.springframework.util.StringUtils; 14 | import org.springframework.web.bind.annotation.DeleteMapping; 15 | import org.springframework.web.bind.annotation.GetMapping; 16 | import org.springframework.web.bind.annotation.PatchMapping; 17 | import org.springframework.web.bind.annotation.PathVariable; 18 | import org.springframework.web.bind.annotation.PostMapping; 19 | import org.springframework.web.bind.annotation.RequestBody; 20 | import org.springframework.web.bind.annotation.RequestMapping; 21 | import org.springframework.web.bind.annotation.RequestParam; 22 | import org.springframework.web.bind.annotation.ResponseStatus; 23 | import org.springframework.web.bind.annotation.RestController; 24 | 25 | import java.security.Principal; 26 | import java.util.List; 27 | import java.util.stream.Collectors; 28 | 29 | import static com.ivanfranchin.bookservice.config.SwaggerConfig.BEARER_KEY_SECURITY_SCHEME; 30 | 31 | @Slf4j 32 | @RequiredArgsConstructor 33 | @RestController 34 | @RequestMapping("/api/books") 35 | public class BookController { 36 | 37 | private final BookService bookService; 38 | 39 | @Operation(summary = "Get list of book. It can be filtered by author name") 40 | @GetMapping 41 | public List getBooks(@RequestParam(required = false) String authorName) { 42 | boolean filterByAuthorName = StringUtils.hasText(authorName); 43 | if (filterByAuthorName) { 44 | log.info("Get books filtering by authorName equals to {}", authorName); 45 | } else { 46 | log.info("Get books"); 47 | } 48 | List books = filterByAuthorName ? bookService.getBooksByAuthorName(authorName) : bookService.getBooks(); 49 | return books.stream().map(BookResponse::from).collect(Collectors.toList()); 50 | } 51 | 52 | @Operation(summary = "Get book by id") 53 | @GetMapping("/{id}") 54 | public BookResponse getBookById(@PathVariable String id) { 55 | log.info("Get books with id equals to {}", id); 56 | Book book = bookService.validateAndGetBookById(id); 57 | return BookResponse.from(book); 58 | } 59 | 60 | @Operation( 61 | summary = "Create a book", 62 | security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 63 | @ResponseStatus(HttpStatus.CREATED) 64 | @PostMapping 65 | public BookResponse createBook(@Valid @RequestBody CreateBookRequest createBookRequest, Principal principal) { 66 | log.info("Post request made by {} to create a book {}", principal.getName(), createBookRequest); 67 | Book book = bookService.saveBook(Book.from(createBookRequest)); 68 | return BookResponse.from(book); 69 | } 70 | 71 | @Operation( 72 | summary = "Update a book", 73 | security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 74 | @PatchMapping("/{id}") 75 | public BookResponse updateBook(@PathVariable String id, 76 | @Valid @RequestBody UpdateBookRequest updateBookRequest, 77 | Principal principal) { 78 | log.info("Patch request made by {} to update book with id {}. New values {}", principal.getName(), id, updateBookRequest); 79 | Book book = bookService.validateAndGetBookById(id); 80 | Book.updateFrom(updateBookRequest, book); 81 | book = bookService.saveBook(book); 82 | return BookResponse.from(book); 83 | } 84 | 85 | @Operation( 86 | summary = "Delete a book", 87 | security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 88 | @DeleteMapping("/{id}") 89 | public BookResponse deleteBook(@PathVariable String id, Principal principal) { 90 | log.info("Delete request made by {} to remove book with id {}", principal.getName(), id); 91 | Book book = bookService.validateAndGetBookById(id); 92 | bookService.deleteBook(book); 93 | return BookResponse.from(book); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/book/BookRepository.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.book; 2 | 3 | import com.ivanfranchin.bookservice.book.model.Book; 4 | import org.springframework.data.mongodb.repository.MongoRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.List; 8 | 9 | @Repository 10 | public interface BookRepository extends MongoRepository { 11 | 12 | List findByAuthorNameLike(String authorName); 13 | } 14 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/book/BookService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.book; 2 | 3 | import com.ivanfranchin.bookservice.book.exception.BookNotFoundException; 4 | import com.ivanfranchin.bookservice.book.model.Book; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.util.List; 9 | 10 | @RequiredArgsConstructor 11 | @Service 12 | public class BookService { 13 | 14 | private final BookRepository bookRepository; 15 | 16 | public List getBooks() { 17 | return bookRepository.findAll(); 18 | } 19 | 20 | public List getBooksByAuthorName(String authorName) { 21 | return bookRepository.findByAuthorNameLike(authorName); 22 | } 23 | 24 | public Book saveBook(Book book) { 25 | return bookRepository.save(book); 26 | } 27 | 28 | public void deleteBook(Book book) { 29 | bookRepository.delete(book); 30 | } 31 | 32 | public Book validateAndGetBookById(String id) { 33 | return bookRepository.findById(id).orElseThrow(() -> new BookNotFoundException(id)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/book/dto/BookResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.book.dto; 2 | 3 | import com.ivanfranchin.bookservice.book.model.Book; 4 | 5 | import java.math.BigDecimal; 6 | 7 | public record BookResponse(String id, String authorName, String title, BigDecimal price) { 8 | 9 | public static BookResponse from(Book book) { 10 | return new BookResponse(book.getId(), book.getAuthorName(), book.getTitle(), book.getPrice()); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/book/dto/CreateBookRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.book.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.NotNull; 6 | import jakarta.validation.constraints.Positive; 7 | 8 | import java.math.BigDecimal; 9 | 10 | public record CreateBookRequest( 11 | @Schema(example = "Ivan Franchin") @NotBlank String authorName, 12 | @Schema(example = "SpringBoot") @NotBlank String title, 13 | @Schema(example = "10.5") @NotNull @Positive BigDecimal price) { 14 | } 15 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/book/dto/UpdateBookRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.book.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | 5 | import java.math.BigDecimal; 6 | 7 | public record UpdateBookRequest( 8 | @Schema(example = "Ivan G. Franchin") String authorName, 9 | @Schema(example = "Java 16") String title, @Schema(example = "20.5") BigDecimal price) { 10 | } 11 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/book/exception/BookNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.book.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 BookNotFoundException extends RuntimeException { 8 | 9 | public BookNotFoundException(String id) { 10 | super(String.format("Book with id '%s' not found.", id)); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/book/model/Book.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.book.model; 2 | 3 | import com.ivanfranchin.bookservice.book.dto.CreateBookRequest; 4 | import com.ivanfranchin.bookservice.book.dto.UpdateBookRequest; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | import org.springframework.data.annotation.Id; 9 | import org.springframework.data.mongodb.core.mapping.Document; 10 | 11 | import java.math.BigDecimal; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | @Document(collection = "books") 17 | public class Book { 18 | 19 | @Id 20 | private String id; 21 | private String authorName; 22 | private String title; 23 | private BigDecimal price; 24 | 25 | public Book(String authorName, String title, BigDecimal price) { 26 | this.authorName = authorName; 27 | this.title = title; 28 | this.price = price; 29 | } 30 | 31 | public static Book from(CreateBookRequest createBookRequest) { 32 | return new Book( 33 | createBookRequest.authorName(), 34 | createBookRequest.title(), 35 | createBookRequest.price() 36 | ); 37 | } 38 | 39 | public static void updateFrom(UpdateBookRequest updateBookRequest, Book book) { 40 | if (updateBookRequest.authorName() != null) { 41 | book.setAuthorName(updateBookRequest.authorName()); 42 | } 43 | if (updateBookRequest.title() != null) { 44 | book.setTitle(updateBookRequest.title()); 45 | } 46 | if (updateBookRequest.price() != null) { 47 | book.setPrice(updateBookRequest.price()); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/config/ErrorAttributesConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.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, options.including(Include.EXCEPTION, Include.MESSAGE, Include.BINDING_ERRORS)); 22 | } 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/config/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.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 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/security/JwtAuthConverter.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.security; 2 | 3 | import org.springframework.core.convert.converter.Converter; 4 | import org.springframework.security.authentication.AbstractAuthenticationToken; 5 | import org.springframework.security.core.GrantedAuthority; 6 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 7 | import org.springframework.security.oauth2.jwt.Jwt; 8 | import org.springframework.security.oauth2.jwt.JwtClaimNames; 9 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; 10 | import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; 11 | import org.springframework.stereotype.Component; 12 | 13 | import java.util.Collection; 14 | import java.util.Collections; 15 | import java.util.Map; 16 | import java.util.stream.Collectors; 17 | import java.util.stream.Stream; 18 | 19 | @Component 20 | public class JwtAuthConverter implements Converter { 21 | 22 | private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); 23 | 24 | private final JwtAuthConverterProperties properties; 25 | 26 | public JwtAuthConverter(JwtAuthConverterProperties properties) { 27 | this.properties = properties; 28 | } 29 | 30 | @Override 31 | public AbstractAuthenticationToken convert(Jwt jwt) { 32 | Collection authorities = Stream.concat( 33 | jwtGrantedAuthoritiesConverter.convert(jwt).stream(), 34 | extractResourceRoles(jwt).stream()).collect(Collectors.toSet()); 35 | return new JwtAuthenticationToken(jwt, authorities, getPrincipalClaimName(jwt)); 36 | } 37 | 38 | private String getPrincipalClaimName(Jwt jwt) { 39 | String claimName = JwtClaimNames.SUB; 40 | if (properties.getPrincipalAttribute() != null) { 41 | claimName = properties.getPrincipalAttribute(); 42 | } 43 | return jwt.getClaim(claimName); 44 | } 45 | 46 | private Collection extractResourceRoles(Jwt jwt) { 47 | Map resourceAccess = jwt.getClaim("resource_access"); 48 | Map resource; 49 | Collection resourceRoles; 50 | if (resourceAccess == null 51 | || (resource = (Map) resourceAccess.get(properties.getResourceId())) == null 52 | || (resourceRoles = (Collection) resource.get("roles")) == null) { 53 | return Collections.emptySet(); 54 | } 55 | return resourceRoles.stream() 56 | .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) 57 | .collect(Collectors.toSet()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/security/JwtAuthConverterProperties.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.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 | -------------------------------------------------------------------------------- /book-service/src/main/java/com/ivanfranchin/bookservice/security/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.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.annotation.web.configurers.AbstractHttpConfigurer; 9 | import org.springframework.security.config.http.SessionCreationPolicy; 10 | import org.springframework.security.web.SecurityFilterChain; 11 | 12 | @RequiredArgsConstructor 13 | @Configuration 14 | public class SecurityConfig { 15 | 16 | private final JwtAuthConverter jwtAuthConverter; 17 | 18 | @Bean 19 | SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 20 | return http 21 | .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests 22 | .requestMatchers(HttpMethod.GET, "/api/books", "/api/books/**").permitAll() 23 | .requestMatchers("/api/books", "/api/books/**").hasRole(MANAGE_BOOKS) 24 | .requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs", "/v3/api-docs/**").permitAll() 25 | .anyRequest().authenticated()) 26 | .oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt( 27 | jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter))) 28 | .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 29 | .cors(AbstractHttpConfigurer::disable) 30 | .csrf(AbstractHttpConfigurer::disable) 31 | .build(); 32 | } 33 | 34 | private static final String MANAGE_BOOKS = "manage_books"; 35 | } 36 | -------------------------------------------------------------------------------- /book-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: book-service 4 | data: 5 | mongodb: 6 | host: ${MONGODB_HOST:localhost} 7 | port: ${MONGODB_PORT:27017} 8 | database: bookdb 9 | security: 10 | oauth2: 11 | resourceserver: 12 | jwt: 13 | issuer-uri: http://${KEYCLOAK_HOST:localhost}:${KEYCLOAK_PORT:8080}/realms/company-services 14 | 15 | jwt: 16 | auth: 17 | converter: 18 | resource-id: ${spring.application.name} 19 | principal-attribute: preferred_username 20 | 21 | springdoc: 22 | swagger-ui: 23 | disable-swagger-default-url: true 24 | -------------------------------------------------------------------------------- /book-service/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ _ _ 2 | | |__ ___ ___ | | __ ___ ___ _ ____ _(_) ___ ___ 3 | | '_ \ / _ \ / _ \| |/ /____/ __|/ _ \ '__\ \ / / |/ __/ _ \ 4 | | |_) | (_) | (_) | <_____\__ \ __/ | \ V /| | (_| __/ 5 | |_.__/ \___/ \___/|_|\_\ |___/\___|_| \_/ |_|\___\___| 6 | :: Spring Boot :: ${spring-boot.formatted-version} 7 | -------------------------------------------------------------------------------- /book-service/src/test/java/com/ivanfranchin/bookservice/controller/BookControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.controller; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.ivanfranchin.bookservice.book.BookController; 5 | import com.ivanfranchin.bookservice.book.dto.CreateBookRequest; 6 | import com.ivanfranchin.bookservice.book.dto.UpdateBookRequest; 7 | import com.ivanfranchin.bookservice.book.exception.BookNotFoundException; 8 | import com.ivanfranchin.bookservice.book.model.Book; 9 | import com.ivanfranchin.bookservice.security.JwtAuthConverterProperties; 10 | import com.ivanfranchin.bookservice.security.SecurityConfig; 11 | import com.ivanfranchin.bookservice.book.BookService; 12 | import org.junit.jupiter.api.Test; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 15 | import org.springframework.context.annotation.Import; 16 | import org.springframework.http.MediaType; 17 | import org.springframework.security.test.context.support.WithMockUser; 18 | import org.springframework.test.context.bean.override.mockito.MockitoBean; 19 | import org.springframework.test.web.servlet.MockMvc; 20 | import org.springframework.test.web.servlet.ResultActions; 21 | 22 | import java.math.BigDecimal; 23 | import java.util.Collections; 24 | 25 | import static org.hamcrest.Matchers.is; 26 | import static org.hamcrest.collection.IsCollectionWithSize.hasSize; 27 | import static org.mockito.ArgumentMatchers.any; 28 | import static org.mockito.ArgumentMatchers.anyString; 29 | import static org.mockito.BDDMockito.given; 30 | import static org.mockito.BDDMockito.willDoNothing; 31 | import static org.mockito.BDDMockito.willThrow; 32 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; 33 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 34 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; 35 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 36 | import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; 37 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; 38 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 39 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 40 | 41 | @WebMvcTest(BookController.class) 42 | @Import({JwtAuthConverterProperties.class, SecurityConfig.class}) 43 | class BookControllerTest { 44 | 45 | @Autowired 46 | private MockMvc mockMvc; 47 | 48 | @MockitoBean 49 | private BookService bookService; 50 | 51 | @Autowired 52 | private ObjectMapper objectMapper; 53 | 54 | @Test 55 | void testGetBooksWhenThereIsNone() throws Exception { 56 | given(bookService.getBooks()).willReturn(Collections.emptyList()); 57 | 58 | ResultActions resultActions = mockMvc.perform(get(API_BOOKS_URL)) 59 | .andDo(print()); 60 | 61 | resultActions.andExpect(status().isOk()) 62 | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) 63 | .andExpect(jsonPath("$", hasSize(0))); 64 | } 65 | 66 | @Test 67 | void testGetBooksWhenThereIsOne() throws Exception { 68 | Book book = getDefaultBook(); 69 | given(bookService.getBooks()).willReturn(Collections.singletonList(book)); 70 | 71 | ResultActions resultActions = mockMvc.perform(get(API_BOOKS_URL)) 72 | .andDo(print()); 73 | 74 | resultActions.andExpect(status().isOk()) 75 | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) 76 | .andExpect(jsonPath(JSON_$, hasSize(1))) 77 | .andExpect(jsonPath(JSON_$_0_ID, is(book.getId()))) 78 | .andExpect(jsonPath(JSON_$_0_AUTHOR_NAME, is(book.getAuthorName()))) 79 | .andExpect(jsonPath(JSON_$_0_TITLE, is(book.getTitle()))) 80 | .andExpect(jsonPath(JSON_$_0_PRICE, is(book.getPrice().doubleValue()))); 81 | } 82 | 83 | @Test 84 | void testGetBookByIdWhenExistent() throws Exception { 85 | Book book = getDefaultBook(); 86 | given(bookService.validateAndGetBookById(anyString())).willReturn(book); 87 | 88 | ResultActions resultActions = mockMvc.perform(get(API_BOOKS_ID_URL, book.getId())) 89 | .andDo(print()); 90 | 91 | resultActions.andExpect(status().isOk()) 92 | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) 93 | .andExpect(jsonPath(JSON_$_ID, is(book.getId()))) 94 | .andExpect(jsonPath(JSON_$_AUTHOR_NAME, is(book.getAuthorName()))) 95 | .andExpect(jsonPath(JSON_$_TITLE, is(book.getTitle()))) 96 | .andExpect(jsonPath(JSON_$_PRICE, is(book.getPrice().doubleValue()))); 97 | } 98 | 99 | @Test 100 | @WithMockUser(roles = MANAGE_BOOKS) 101 | void testCreateBook() throws Exception { 102 | CreateBookRequest createBookRequest = new CreateBookRequest("Ivan Franchin", "SpringBoot", BigDecimal.valueOf(29.99)); 103 | Book book = getDefaultBook(); 104 | 105 | given(bookService.saveBook(any(Book.class))).willReturn(book); 106 | 107 | ResultActions resultActions = mockMvc.perform(post(API_BOOKS_URL) 108 | .contentType(MediaType.APPLICATION_JSON) 109 | .content(objectMapper.writeValueAsString(createBookRequest))) 110 | .andDo(print()); 111 | 112 | resultActions.andExpect(status().isCreated()) 113 | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) 114 | .andExpect(jsonPath(JSON_$_ID, is(book.getId()))) 115 | .andExpect(jsonPath(JSON_$_AUTHOR_NAME, is(book.getAuthorName()))) 116 | .andExpect(jsonPath(JSON_$_TITLE, is(book.getTitle()))) 117 | .andExpect(jsonPath(JSON_$_PRICE, is(book.getPrice().doubleValue()))); 118 | } 119 | 120 | @Test 121 | @WithMockUser(roles = MANAGE_BOOKS) 122 | void testUpdateBookWhenExistent() throws Exception { 123 | Book book = getDefaultBook(); 124 | UpdateBookRequest updateBookRequest = new UpdateBookRequest(null, "Java 9", BigDecimal.valueOf(99.99)); 125 | 126 | given(bookService.validateAndGetBookById(anyString())).willReturn(book); 127 | given(bookService.saveBook(any(Book.class))).willReturn(book); 128 | 129 | ResultActions resultActions = mockMvc.perform(patch(API_BOOKS_ID_URL, book.getId()) 130 | .contentType(MediaType.APPLICATION_JSON) 131 | .content(objectMapper.writeValueAsString(updateBookRequest))) 132 | .andDo(print()); 133 | 134 | resultActions.andExpect(status().isOk()) 135 | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) 136 | .andExpect(jsonPath(JSON_$_ID, is(book.getId()))) 137 | .andExpect(jsonPath(JSON_$_AUTHOR_NAME, is(book.getAuthorName()))) 138 | .andExpect(jsonPath(JSON_$_TITLE, is(updateBookRequest.title()))) 139 | .andExpect(jsonPath(JSON_$_PRICE, is(updateBookRequest.price().doubleValue()))); 140 | } 141 | 142 | @Test 143 | @WithMockUser(roles = MANAGE_BOOKS) 144 | void testUpdateBookWhenNonExistent() throws Exception { 145 | UpdateBookRequest updateBookRequest = new UpdateBookRequest(null, "SpringBoot 2", null); 146 | 147 | willThrow(BookNotFoundException.class).given(bookService).validateAndGetBookById(anyString()); 148 | 149 | ResultActions resultActions = mockMvc.perform(patch(API_BOOKS_ID_URL, "123") 150 | .contentType(MediaType.APPLICATION_JSON) 151 | .content(objectMapper.writeValueAsString(updateBookRequest))) 152 | .andDo(print()); 153 | 154 | resultActions.andExpect(status().isNotFound()); 155 | } 156 | 157 | @Test 158 | @WithMockUser(roles = MANAGE_BOOKS) 159 | void testDeleteBookWhenExistent() throws Exception { 160 | Book book = getDefaultBook(); 161 | 162 | given(bookService.validateAndGetBookById(anyString())).willReturn(book); 163 | willDoNothing().given(bookService).deleteBook(any(Book.class)); 164 | 165 | ResultActions resultActions = mockMvc.perform(delete(API_BOOKS_ID_URL, book.getId())) 166 | .andDo(print()); 167 | 168 | resultActions.andExpect(status().isOk()) 169 | .andExpect(content().contentType(MediaType.APPLICATION_JSON)) 170 | .andExpect(jsonPath(JSON_$_ID, is(book.getId()))) 171 | .andExpect(jsonPath(JSON_$_AUTHOR_NAME, is(book.getAuthorName()))) 172 | .andExpect(jsonPath(JSON_$_TITLE, is(book.getTitle()))) 173 | .andExpect(jsonPath(JSON_$_PRICE, is(book.getPrice().doubleValue()))); 174 | } 175 | 176 | @Test 177 | @WithMockUser(roles = MANAGE_BOOKS) 178 | void testDeleteBookWhenNonExistent() throws Exception { 179 | willThrow(BookNotFoundException.class).given(bookService).validateAndGetBookById(anyString()); 180 | 181 | ResultActions resultActions = mockMvc.perform(delete(API_BOOKS_ID_URL, "123")) 182 | .andDo(print()); 183 | 184 | resultActions.andExpect(status().isNotFound()); 185 | } 186 | 187 | @Test 188 | @WithMockUser(roles = FAKE_ROLE) 189 | void testCreateBookUsingInvalidRoles() throws Exception { 190 | CreateBookRequest createBookRequest = new CreateBookRequest("Ivan Franchin", "SpringBoot", BigDecimal.valueOf(29.99)); 191 | 192 | ResultActions resultActions = mockMvc.perform(post(API_BOOKS_URL) 193 | .contentType(MediaType.APPLICATION_JSON) 194 | .content(objectMapper.writeValueAsString(createBookRequest))) 195 | .andDo(print()); 196 | 197 | resultActions.andExpect(status().isForbidden()); 198 | } 199 | 200 | @Test 201 | @WithMockUser(roles = FAKE_ROLE) 202 | void testUpdateBookUsingInvalidRoles() throws Exception { 203 | UpdateBookRequest updateBookRequest = new UpdateBookRequest(null, "Java 9", BigDecimal.valueOf(99.99)); 204 | 205 | ResultActions resultActions = mockMvc.perform(patch(API_BOOKS_ID_URL, "123") 206 | .contentType(MediaType.APPLICATION_JSON) 207 | .content(objectMapper.writeValueAsString(updateBookRequest))) 208 | .andDo(print()); 209 | 210 | resultActions.andExpect(status().isForbidden()); 211 | } 212 | 213 | @Test 214 | @WithMockUser(roles = FAKE_ROLE) 215 | void testDeleteBookUsingInvalidRoles() throws Exception { 216 | ResultActions resultActions = mockMvc.perform(delete(API_BOOKS_ID_URL, "123")) 217 | .andDo(print()); 218 | 219 | resultActions.andExpect(status().isForbidden()); 220 | } 221 | 222 | private Book getDefaultBook() { 223 | return new Book("123", "Ivan Franchin", "SpringBoot", BigDecimal.valueOf(29.99)); 224 | } 225 | 226 | private static final String MANAGE_BOOKS = "manage_books"; 227 | private static final String FAKE_ROLE = "fake_role"; 228 | 229 | private static final String API_BOOKS_URL = "/api/books"; 230 | private static final String API_BOOKS_ID_URL = "/api/books/{id}"; 231 | 232 | private static final String JSON_$ = "$"; 233 | 234 | private static final String JSON_$_ID = "$.id"; 235 | private static final String JSON_$_AUTHOR_NAME = "$.authorName"; 236 | private static final String JSON_$_TITLE = "$.title"; 237 | private static final String JSON_$_PRICE = "$.price"; 238 | 239 | private static final String JSON_$_0_ID = "$[0].id"; 240 | private static final String JSON_$_0_AUTHOR_NAME = "$[0].authorName"; 241 | private static final String JSON_$_0_TITLE = "$[0].title"; 242 | private static final String JSON_$_0_PRICE = "$[0].price"; 243 | } -------------------------------------------------------------------------------- /book-service/src/test/java/com/ivanfranchin/bookservice/dto/BookResponseTest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.dto; 2 | 3 | import com.ivanfranchin.bookservice.book.dto.BookResponse; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.json.JsonTest; 7 | import org.springframework.boot.test.json.JacksonTester; 8 | import org.springframework.boot.test.json.JsonContent; 9 | 10 | import java.io.IOException; 11 | import java.math.BigDecimal; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | 15 | @JsonTest 16 | class BookResponseTest { 17 | 18 | @Autowired 19 | private JacksonTester jacksonTester; 20 | 21 | @Test 22 | void testSerialize() throws IOException { 23 | BookResponse bookResponse = new BookResponse("123", "Ivan Franchin", "SpringBoot", BigDecimal.valueOf(29.99)); 24 | 25 | JsonContent jsonContent = jacksonTester.write(bookResponse); 26 | 27 | assertThat(jsonContent) 28 | .hasJsonPathStringValue("@.id") 29 | .extractingJsonPathStringValue("@.id").isEqualTo(bookResponse.id()); 30 | 31 | assertThat(jsonContent) 32 | .hasJsonPathStringValue("@.authorName") 33 | .extractingJsonPathStringValue("@.authorName").isEqualTo(bookResponse.authorName()); 34 | 35 | assertThat(jsonContent) 36 | .hasJsonPathStringValue("@.title") 37 | .extractingJsonPathStringValue("@.title").isEqualTo(bookResponse.title()); 38 | 39 | assertThat(jsonContent) 40 | .hasJsonPathNumberValue("@.price") 41 | .extractingJsonPathNumberValue("@.price").isEqualTo(bookResponse.price().doubleValue()); 42 | } 43 | 44 | @Test 45 | void testDeserialize() throws IOException { 46 | String content = "{\"id\":\"123\",\"authorName\":\"Ivan Franchin\",\"title\":\"SpringBoot\",\"price\":29.99}"; 47 | 48 | BookResponse bookResponse = jacksonTester.parseObject(content); 49 | 50 | assertThat(bookResponse.id()).hasToString("123"); 51 | assertThat(bookResponse.authorName()).isEqualTo("Ivan Franchin"); 52 | assertThat(bookResponse.title()).isEqualTo("SpringBoot"); 53 | assertThat(bookResponse.price().doubleValue()).isEqualTo(29.99); 54 | } 55 | } -------------------------------------------------------------------------------- /book-service/src/test/java/com/ivanfranchin/bookservice/dto/CreateBookRequestTest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.dto; 2 | 3 | import com.ivanfranchin.bookservice.book.dto.CreateBookRequest; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.json.JsonTest; 7 | import org.springframework.boot.test.json.JacksonTester; 8 | import org.springframework.boot.test.json.JsonContent; 9 | 10 | import java.io.IOException; 11 | import java.math.BigDecimal; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | 15 | @JsonTest 16 | class CreateBookRequestTest { 17 | 18 | @Autowired 19 | private JacksonTester jacksonTester; 20 | 21 | @Test 22 | void testSerialize() throws IOException { 23 | CreateBookRequest createBookRequest = new CreateBookRequest("Ivan Franchin", "SpringBoot", BigDecimal.valueOf(29.99)); 24 | 25 | JsonContent jsonContent = jacksonTester.write(createBookRequest); 26 | 27 | assertThat(jsonContent) 28 | .hasJsonPathStringValue("@.authorName") 29 | .extractingJsonPathStringValue("@.authorName").isEqualTo(createBookRequest.authorName()); 30 | 31 | assertThat(jsonContent) 32 | .hasJsonPathStringValue("@.title") 33 | .extractingJsonPathStringValue("@.title").isEqualTo(createBookRequest.title()); 34 | 35 | assertThat(jsonContent) 36 | .hasJsonPathNumberValue("@.price") 37 | .extractingJsonPathNumberValue("@.price").isEqualTo(createBookRequest.price().doubleValue()); 38 | } 39 | 40 | @Test 41 | void testDeserialize() throws IOException { 42 | String content = "{\"authorName\":\"Ivan Franchin\",\"title\":\"SpringBoot\",\"price\":29.99}"; 43 | 44 | CreateBookRequest createBookRequest = jacksonTester.parseObject(content); 45 | 46 | assertThat(createBookRequest.authorName()).isEqualTo("Ivan Franchin"); 47 | assertThat(createBookRequest.title()).isEqualTo("SpringBoot"); 48 | assertThat(createBookRequest.price().doubleValue()).isEqualTo(29.99); 49 | } 50 | } -------------------------------------------------------------------------------- /book-service/src/test/java/com/ivanfranchin/bookservice/dto/UpdateBookRequestTest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.dto; 2 | 3 | import com.ivanfranchin.bookservice.book.dto.UpdateBookRequest; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.json.JsonTest; 7 | import org.springframework.boot.test.json.JacksonTester; 8 | import org.springframework.boot.test.json.JsonContent; 9 | 10 | import java.io.IOException; 11 | import java.math.BigDecimal; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | 15 | @JsonTest 16 | class UpdateBookRequestTest { 17 | 18 | @Autowired 19 | private JacksonTester jacksonTester; 20 | 21 | @Test 22 | void testSerialize() throws IOException { 23 | UpdateBookRequest updateBookRequest = new UpdateBookRequest("Ivan Franchin", "SpringBoot", BigDecimal.valueOf(29.99)); 24 | 25 | JsonContent jsonContent = jacksonTester.write(updateBookRequest); 26 | 27 | assertThat(jsonContent) 28 | .hasJsonPathStringValue("@.authorName") 29 | .extractingJsonPathStringValue("@.authorName").isEqualTo(updateBookRequest.authorName()); 30 | 31 | assertThat(jsonContent) 32 | .hasJsonPathStringValue("@.title") 33 | .extractingJsonPathStringValue("@.title").isEqualTo(updateBookRequest.title()); 34 | 35 | assertThat(jsonContent) 36 | .hasJsonPathNumberValue("@.price") 37 | .extractingJsonPathNumberValue("@.price").isEqualTo(updateBookRequest.price().doubleValue()); 38 | } 39 | 40 | @Test 41 | void testDeserialize() throws IOException { 42 | String content = "{\"authorName\":\"Ivan Franchin\",\"title\":\"SpringBoot\",\"price\":29.99}"; 43 | 44 | UpdateBookRequest updateBookRequest = jacksonTester.parseObject(content); 45 | 46 | assertThat(updateBookRequest.authorName()).isEqualTo("Ivan Franchin"); 47 | assertThat(updateBookRequest.title()).isEqualTo("SpringBoot"); 48 | assertThat(updateBookRequest.price().doubleValue()).isEqualTo(29.99); 49 | } 50 | } -------------------------------------------------------------------------------- /book-service/src/test/java/com/ivanfranchin/bookservice/repository/BookRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.repository; 2 | 3 | import com.ivanfranchin.bookservice.book.BookRepository; 4 | import com.ivanfranchin.bookservice.book.model.Book; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest; 9 | import org.springframework.boot.testcontainers.service.connection.ServiceConnection; 10 | import org.springframework.data.mongodb.core.MongoTemplate; 11 | import org.testcontainers.containers.MongoDBContainer; 12 | import org.testcontainers.junit.jupiter.Container; 13 | import org.testcontainers.junit.jupiter.Testcontainers; 14 | 15 | import java.math.BigDecimal; 16 | import java.util.List; 17 | import java.util.Optional; 18 | 19 | import static org.assertj.core.api.Assertions.assertThat; 20 | 21 | @Testcontainers 22 | @DataMongoTest 23 | class BookRepositoryTest { 24 | 25 | @Container 26 | @ServiceConnection 27 | private static final MongoDBContainer mongoDBContainer = new MongoDBContainer("mongo:8.0.6"); 28 | 29 | @Autowired 30 | private MongoTemplate mongoTemplate; 31 | 32 | @Autowired 33 | private BookRepository bookRepository; 34 | 35 | @BeforeEach 36 | void setUp() { 37 | bookRepository.deleteAll(); 38 | } 39 | 40 | @Test 41 | void testFindAllWhenThereIsNone() { 42 | List books = bookRepository.findAll(); 43 | 44 | assertThat(books).isEmpty(); 45 | } 46 | 47 | @Test 48 | void testFindAllWhenThereIsOne() { 49 | mongoTemplate.save(getDefaultBook()); 50 | 51 | List books = bookRepository.findAll(); 52 | 53 | assertThat(books).hasSize(1); 54 | } 55 | 56 | @Test 57 | void testFindByIdWhenNonExistent() { 58 | Optional bookFound = bookRepository.findById("123"); 59 | 60 | assertThat(bookFound).isNotPresent(); 61 | } 62 | 63 | @Test 64 | void testFindByIdWhenExistent() { 65 | Book book = mongoTemplate.save(getDefaultBook()); 66 | 67 | Optional bookFound = bookRepository.findById(book.getId()); 68 | 69 | assertThat(bookFound).isPresent(); 70 | assertThat(bookFound.get()).isEqualTo(book); 71 | } 72 | 73 | @Test 74 | void testFindByAuthorNameLikeWhenThereIsOne() { 75 | mongoTemplate.save(getDefaultBook()); 76 | 77 | List books = bookRepository.findByAuthorNameLike("Franchin"); 78 | 79 | assertThat(books).hasSize(1); 80 | } 81 | 82 | @Test 83 | void testDeleteWhenExistent() { 84 | Book book = mongoTemplate.save(getDefaultBook()); 85 | 86 | Optional bookOptional = bookRepository.findById(book.getId()); 87 | assertThat(bookOptional).isPresent(); 88 | 89 | bookRepository.delete(book); 90 | 91 | bookOptional = bookRepository.findById(book.getId()); 92 | assertThat(bookOptional).isNotPresent(); 93 | } 94 | 95 | @Test 96 | void testSaveWhenUpdatingExistentRecord() { 97 | Book book = mongoTemplate.save(getDefaultBook()); 98 | 99 | Optional bookOptional = bookRepository.findById(book.getId()); 100 | assertThat(bookOptional).isPresent(); 101 | assertThat(bookOptional.get()).isEqualTo(book); 102 | 103 | book.setAuthorName("Ivan Franchin 2"); 104 | book.setTitle("Java 8"); 105 | book.setPrice(BigDecimal.valueOf(12.99)); 106 | 107 | bookRepository.save(book); 108 | 109 | bookOptional = bookRepository.findById(book.getId()); 110 | assertThat(bookOptional).isPresent(); 111 | assertThat(bookOptional.get().getAuthorName()).isEqualTo(book.getAuthorName()); 112 | assertThat(bookOptional.get().getTitle()).isEqualTo(book.getTitle()); 113 | assertThat(bookOptional.get().getPrice()).isEqualTo(book.getPrice()); 114 | } 115 | 116 | private Book getDefaultBook() { 117 | return new Book("Ivan Franchin", "SpringBoot", BigDecimal.valueOf(29.99)); 118 | } 119 | } -------------------------------------------------------------------------------- /book-service/src/test/java/com/ivanfranchin/bookservice/service/BookServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookservice.service; 2 | 3 | import com.ivanfranchin.bookservice.book.BookService; 4 | import com.ivanfranchin.bookservice.book.exception.BookNotFoundException; 5 | import com.ivanfranchin.bookservice.book.model.Book; 6 | import com.ivanfranchin.bookservice.book.BookRepository; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.context.annotation.Import; 11 | import org.springframework.test.context.bean.override.mockito.MockitoBean; 12 | import org.springframework.test.context.junit.jupiter.SpringExtension; 13 | 14 | import java.math.BigDecimal; 15 | import java.util.Collections; 16 | import java.util.List; 17 | import java.util.Optional; 18 | 19 | import static org.assertj.core.api.Assertions.assertThat; 20 | import static org.junit.jupiter.api.Assertions.assertThrows; 21 | import static org.mockito.ArgumentMatchers.any; 22 | import static org.mockito.ArgumentMatchers.anyString; 23 | import static org.mockito.BDDMockito.given; 24 | 25 | @ExtendWith(SpringExtension.class) 26 | @Import(BookService.class) 27 | class BookServiceTest { 28 | 29 | @Autowired 30 | private BookService bookService; 31 | 32 | @MockitoBean 33 | private BookRepository bookRepository; 34 | 35 | @Test 36 | void testSaveBook() { 37 | Book book = getDefaultBook(); 38 | given(bookRepository.save(any(Book.class))).willReturn(book); 39 | 40 | Book bookSaved = bookService.saveBook(book); 41 | assertThat(bookSaved).isEqualTo(book); 42 | } 43 | 44 | @Test 45 | void testGetBooksWhenThereIsNone() { 46 | given(bookRepository.findAll()).willReturn(Collections.emptyList()); 47 | 48 | List booksFound = bookService.getBooks(); 49 | assertThat(booksFound).isEmpty(); 50 | } 51 | 52 | @Test 53 | void testGetBooksWhenThereIsOne() { 54 | Book book = getDefaultBook(); 55 | given(bookRepository.findAll()).willReturn(Collections.singletonList(book)); 56 | 57 | List booksFound = bookService.getBooks(); 58 | assertThat(booksFound).hasSize(1); 59 | assertThat(booksFound.getFirst()).isEqualTo(book); 60 | } 61 | 62 | @Test 63 | void testGetBooksByAuthorNameWhenAuthorHasOneBook() { 64 | Book book = getDefaultBook(); 65 | given(bookRepository.findByAuthorNameLike(book.getAuthorName())).willReturn(Collections.singletonList(book)); 66 | 67 | List booksFound = bookService.getBooksByAuthorName(book.getAuthorName()); 68 | assertThat(booksFound).hasSize(1); 69 | assertThat(booksFound.getFirst()).isEqualTo(book); 70 | } 71 | 72 | @Test 73 | void testValidateAndGetBookWhenNonExistent() { 74 | given(bookRepository.findById(anyString())).willReturn(Optional.empty()); 75 | 76 | Throwable exception = assertThrows(BookNotFoundException.class, () -> bookService.validateAndGetBookById("123")); 77 | assertThat("Book with id '123' not found.").isEqualTo(exception.getMessage()); 78 | } 79 | 80 | @Test 81 | void testValidateAndGetBookWhenExistent() { 82 | Book book = getDefaultBook(); 83 | given(bookRepository.findById(anyString())).willReturn(Optional.of(book)); 84 | 85 | Book bookFound = bookService.validateAndGetBookById(book.getId()); 86 | assertThat(bookFound).isEqualTo(book); 87 | } 88 | 89 | private Book getDefaultBook() { 90 | Book book = new Book("Ivan Franchin", "SpringBoot", BigDecimal.valueOf(29.99)); 91 | book.setId("123"); 92 | return book; 93 | } 94 | } -------------------------------------------------------------------------------- /build-docker-images.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DOCKER_IMAGE_PREFIX="ivanfranchin" 4 | APP_NAME="book-service" 5 | APP_VERSION="1.0.0" 6 | DOCKER_IMAGE_NAME="${DOCKER_IMAGE_PREFIX}/${APP_NAME}:${APP_VERSION}" 7 | 8 | ./gradlew \ 9 | "$APP_NAME":clean \ 10 | "$APP_NAME":bootBuildImage \ 11 | -x test \ 12 | -x integrationTest \ 13 | --imageName="$DOCKER_IMAGE_NAME" 14 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | 3 | } 4 | 5 | subprojects { 6 | apply plugin: "java" 7 | apply plugin: "idea" 8 | 9 | repositories { 10 | mavenLocal() 11 | mavenCentral() 12 | } 13 | } -------------------------------------------------------------------------------- /documentation/book-service-swagger.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-keycloak-mongodb-testcontainers/300bbaa8ffea750ae4f8b022b1b04d5682d685fb/documentation/book-service-swagger.jpeg -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-keycloak-mongodb-testcontainers/300bbaa8ffea750ae4f8b022b1b04d5682d685fb/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /init-environment.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | MONGO_VERSION="8.0.6" 4 | KEYCLOAK_VERSION="26.2.1" 5 | 6 | source scripts/my-functions.sh 7 | 8 | echo 9 | echo "Starting environment" 10 | echo "====================" 11 | 12 | echo 13 | echo "Creating network" 14 | echo "----------------" 15 | docker network create springboot-keycloak-mongodb-testcontainers-net 16 | 17 | echo 18 | echo "Starting mongodb" 19 | echo "----------------" 20 | docker run -d \ 21 | --name mongodb \ 22 | -p 27017:27017 \ 23 | --restart=unless-stopped \ 24 | --network=springboot-keycloak-mongodb-testcontainers-net \ 25 | --health-cmd="echo 'db.stats().ok' | mongosh localhost:27017/bookdb --quiet" \ 26 | mongo:${MONGO_VERSION} 27 | 28 | echo 29 | echo "Starting keycloak" 30 | echo "-----------------" 31 | docker run -d \ 32 | --name keycloak \ 33 | -p 8080:8080 \ 34 | -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ 35 | -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ 36 | -e KC_DB=dev-mem \ 37 | --restart=unless-stopped \ 38 | --network=springboot-keycloak-mongodb-testcontainers-net \ 39 | --health-cmd="curl -f http://localhost:8080/health/ready || exit 1" \ 40 | quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} start-dev 41 | 42 | echo 43 | wait_for_container_log "mongodb" "Waiting for connections" 44 | 45 | echo 46 | wait_for_container_log "keycloak" "started in" 47 | 48 | echo 49 | echo "Environment Up and Running" 50 | echo "==========================" 51 | echo -------------------------------------------------------------------------------- /init-keycloak.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | KEYCLOAK_HOST_PORT=${1:-"localhost:8080"} 4 | echo 5 | echo "KEYCLOAK_HOST_PORT: $KEYCLOAK_HOST_PORT" 6 | 7 | echo 8 | echo "Getting admin access token" 9 | echo "--------------------------" 10 | 11 | ADMIN_TOKEN=$(curl -s -X POST "http://$KEYCLOAK_HOST_PORT/realms/master/protocol/openid-connect/token" \ 12 | -H "Content-Type: application/x-www-form-urlencoded" \ 13 | -d "username=admin" \ 14 | -d 'password=admin' \ 15 | -d 'grant_type=password' \ 16 | -d 'client_id=admin-cli' | jq -r '.access_token') 17 | 18 | echo "ADMIN_TOKEN=$ADMIN_TOKEN" 19 | echo 20 | 21 | echo "Creating realm" 22 | echo "--------------" 23 | 24 | curl -i -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms" \ 25 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 26 | -H "Content-Type: application/json" \ 27 | -d '{"realm": "company-services", "enabled": true}' 28 | 29 | echo "Get Required Action Verify Profile" 30 | echo "----------------------------------" 31 | 32 | VERIFY_PROFILE_REQUIRED_ACTION=$(curl -s "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/authentication/required-actions/VERIFY_PROFILE" \ 33 | -H "Authorization: Bearer $ADMIN_TOKEN" | jq) 34 | 35 | echo $VERIFY_PROFILE_REQUIRED_ACTION 36 | echo 37 | 38 | echo "Disable Required Action Verify Profile" 39 | echo "--------------------------------------" 40 | 41 | NEW_VERIFY_PROFILE_REQUIRED_ACTION=$(echo "$VERIFY_PROFILE_REQUIRED_ACTION" | jq '.enabled = false') 42 | 43 | echo $NEW_VERIFY_PROFILE_REQUIRED_ACTION 44 | echo 45 | 46 | curl -i -X PUT "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/authentication/required-actions/VERIFY_PROFILE" \ 47 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 48 | -H "Content-Type: application/json" \ 49 | -d "$NEW_VERIFY_PROFILE_REQUIRED_ACTION" 50 | 51 | echo "Creating client" 52 | echo "---------------" 53 | 54 | CLIENT_ID=$(curl -si -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/clients" \ 55 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 56 | -H "Content-Type: application/json" \ 57 | -d '{"clientId": "book-service", "directAccessGrantsEnabled": true, "redirectUris": ["http://localhost:9080/*"]}' \ 58 | | 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}') 59 | 60 | echo "CLIENT_ID=$CLIENT_ID" 61 | echo 62 | 63 | echo "Getting client secret" 64 | echo "---------------------" 65 | 66 | BOOK_SERVICE_CLIENT_SECRET=$(curl -s -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/clients/$CLIENT_ID/client-secret" \ 67 | -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.value') 68 | 69 | echo "BOOK_SERVICE_CLIENT_SECRET=$BOOK_SERVICE_CLIENT_SECRET" 70 | echo 71 | 72 | echo "Creating client role" 73 | echo "--------------------" 74 | 75 | curl -i -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/clients/$CLIENT_ID/roles" \ 76 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 77 | -H "Content-Type: application/json" \ 78 | -d '{"name": "manage_books"}' 79 | 80 | ROLE_ID=$(curl -s "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/clients/$CLIENT_ID/roles" \ 81 | -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[0].id') 82 | 83 | echo "ROLE_ID=$ROLE_ID" 84 | echo 85 | 86 | echo "Creating user" 87 | echo "-------------" 88 | 89 | USER_ID=$(curl -si -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/users" \ 90 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 91 | -H "Content-Type: application/json" \ 92 | -d '{"username": "ivan.franchin", "enabled": true, "credentials": [{"type": "password", "value": "123", "temporary": false}]}' \ 93 | | 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}') 94 | 95 | echo "USER_ID=$USER_ID" 96 | echo 97 | 98 | echo "Setting client role to user" 99 | echo "---------------------------" 100 | 101 | curl -i -X POST "http://$KEYCLOAK_HOST_PORT/admin/realms/company-services/users/$USER_ID/role-mappings/clients/$CLIENT_ID" \ 102 | -H "Authorization: Bearer $ADMIN_TOKEN" \ 103 | -H "Content-Type: application/json" \ 104 | -d '[{"id":"'"$ROLE_ID"'","name":"manage_books"}]' 105 | 106 | echo "Getting user access token" 107 | echo "-------------------------" 108 | 109 | curl -s -X POST "http://$KEYCLOAK_HOST_PORT/realms/company-services/protocol/openid-connect/token" \ 110 | -H "Content-Type: application/x-www-form-urlencoded" \ 111 | -d "username=ivan.franchin" \ 112 | -d "password=123" \ 113 | -d "grant_type=password" \ 114 | -d "client_secret=$BOOK_SERVICE_CLIENT_SECRET" \ 115 | -d "client_id=book-service" | jq -r .access_token 116 | echo 117 | 118 | echo "---------" 119 | echo "BOOK_SERVICE_CLIENT_SECRET=$BOOK_SERVICE_CLIENT_SECRET" 120 | echo "---------" 121 | -------------------------------------------------------------------------------- /remove-docker-images.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker rmi ivanfranchin/book-service:1.0.0 4 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'springboot-keycloak-mongodb-testcontainers' 2 | include 'book-service' 3 | -------------------------------------------------------------------------------- /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 11 | 12 | echo 13 | echo "Removing network" 14 | echo "----------------" 15 | docker network rm springboot-keycloak-mongodb-testcontainers-net 16 | 17 | echo 18 | echo "Environment shutdown successfully" 19 | echo "=================================" 20 | echo --------------------------------------------------------------------------------