├── .github └── FUNDING.yml ├── .gitignore ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── README.md ├── author-book-api ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── authorbookapi │ │ │ ├── AuthorBookApiApplication.java │ │ │ ├── client │ │ │ ├── BookReviewApiClient.java │ │ │ ├── BookReviewApiQueryBuilder.java │ │ │ └── BookReviewApiResult.java │ │ │ ├── config │ │ │ └── GsonConfig.java │ │ │ ├── exception │ │ │ ├── AuthorNotFoundException.java │ │ │ ├── BookDuplicatedIsbnException.java │ │ │ └── BookNotFoundException.java │ │ │ ├── graphql │ │ │ ├── AuthorBookExceptionResolver.java │ │ │ ├── AuthorController.java │ │ │ ├── BookController.java │ │ │ └── input │ │ │ │ ├── AuthorInput.java │ │ │ │ └── BookInput.java │ │ │ ├── model │ │ │ ├── Author.java │ │ │ ├── Book.java │ │ │ ├── BookReview.java │ │ │ └── Review.java │ │ │ ├── repository │ │ │ ├── AuthorRepository.java │ │ │ └── BookRepository.java │ │ │ ├── restapi │ │ │ ├── AuthorController.java │ │ │ ├── BookController.java │ │ │ ├── config │ │ │ │ ├── CorsConfig.java │ │ │ │ ├── ErrorAttributesConfig.java │ │ │ │ └── SwaggerConfig.java │ │ │ └── dto │ │ │ │ ├── AuthorResponse.java │ │ │ │ ├── BookResponse.java │ │ │ │ ├── CreateAuthorRequest.java │ │ │ │ ├── CreateBookRequest.java │ │ │ │ ├── UpdateAuthorRequest.java │ │ │ │ └── UpdateBookRequest.java │ │ │ └── service │ │ │ ├── AuthorService.java │ │ │ ├── AuthorServiceImpl.java │ │ │ ├── BookService.java │ │ │ └── BookServiceImpl.java │ └── resources │ │ ├── application.yml │ │ ├── banner.txt │ │ └── graphql │ │ └── author-book.graphqls │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── authorbookapi │ └── AuthorBookApiApplicationTests.java ├── book-review-api ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── bookreviewapi │ │ │ ├── BookReviewApiApplication.java │ │ │ ├── config │ │ │ ├── CorsConfig.java │ │ │ └── MongoConfig.java │ │ │ ├── exception │ │ │ ├── BookDuplicatedIsbnException.java │ │ │ └── BookNotFoundException.java │ │ │ ├── graphql │ │ │ ├── BookReviewController.java │ │ │ ├── BookReviewExceptionResolver.java │ │ │ └── input │ │ │ │ ├── BookInput.java │ │ │ │ └── ReviewInput.java │ │ │ ├── model │ │ │ ├── Book.java │ │ │ └── Review.java │ │ │ ├── repository │ │ │ └── BookRepository.java │ │ │ └── service │ │ │ ├── BookService.java │ │ │ └── BookServiceImpl.java │ └── resources │ │ ├── application.yml │ │ ├── banner.txt │ │ └── graphql │ │ └── book-review.graphqls │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── bookreviewapi │ └── BookReviewApiApplicationTests.java ├── build-docker-images.sh ├── docker-compose.yml ├── documentation ├── project-diagram.excalidraw └── project-diagram.jpeg ├── mvnw ├── mvnw.cmd ├── pom.xml ├── remove-docker-images.sh ├── scripts └── my-functions.sh ├── start-apps.sh └── stop-apps.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ivangfr 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### STS ### 7 | .apt_generated 8 | .classpath 9 | .factorypath 10 | .project 11 | .settings 12 | .springBeans 13 | .sts4-cache 14 | 15 | ### IntelliJ IDEA ### 16 | .idea 17 | *.iws 18 | *.iml 19 | *.ipr 20 | 21 | ### NetBeans ### 22 | /nbproject/private/ 23 | /nbbuild/ 24 | /dist/ 25 | /nbdist/ 26 | /.nb-gradle/ 27 | build/ 28 | !**/src/main/**/build/ 29 | !**/src/test/**/build/ 30 | 31 | ### VS Code ### 32 | .vscode/ 33 | 34 | ### MAC OS ### 35 | *.DS_Store 36 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # springboot-graphql-databases 2 | 3 | The goal of this project is to explore [`GraphQL`](https://graphql.org). For it, we will implement two [`Spring Boot`](https://docs.spring.io/spring-boot/index.html) Web Java applications: `author-book-api` and `book-review-api`. 4 | 5 | > **Note**: In [`kubernetes-minikube-environment`](https://github.com/ivangfr/kubernetes-minikube-environment/tree/master/author-book-review-graphql) repository, it's shown how to deploy this project in `Kubernetes` (`Minikube`). 6 | 7 | ## Proof-of-Concepts & Articles 8 | 9 | On [ivangfr.github.io](https://ivangfr.github.io), I have compiled my Proof-of-Concepts (PoCs) and articles. You can easily search for the technology you are interested in by using the filter. Who knows, perhaps I have already implemented a PoC or written an article about what you are looking for. 10 | 11 | ## Additional Readings 12 | 13 | - \[**Medium**\] [**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) 14 | - \[**Medium**\] [**Implementing and Securing a Spring Boot GraphQL API with Okta**](https://medium.com/@ivangfr/implementing-and-securing-a-spring-boot-graphql-api-with-okta-78bc997359b4) 15 | 16 | ## Project Diagram 17 | 18 | ![project-diagram](documentation/project-diagram.jpeg) 19 | 20 | ## Applications 21 | 22 | - ### author-book-api 23 | 24 | `Spring Boot` Web Java application that handles `authors` and `books`. It exposes a `GraphQL` endpoint **and** traditional REST API endpoints. `author-book-api` uses [`MySQL`](https://www.mysql.com) as storage and calls `book-review-api` to get the reviews of the books. It uses [`Feign`](https://github.com/OpenFeign/feign) to easily create a client for `book-review-api` and [`Resilience4j`](https://github.com/resilience4j/resilience4j) (fault tolerance library) to handle fallback when `book-review-api` is down. The book `ISBN` is what connects books stored in `author-book-api` with the ones stored in `book-review-api`. 25 | 26 | - ### book-review-api 27 | 28 | `Spring Boot` Web Java application that handles `books` and their `reviews`. It only exposes a `GraphQL` API and uses [`MongoDB`](https://www.mongodb.com) as storage. 29 | 30 | ## Frontend applications 31 | 32 | In the repository [`react-graphql-databases`](https://github.com/ivangfr/react-graphql-databases), I have implemented two [`ReactJS`](https://react.dev) applications `author-book-ui` and `book-review-ui` that are frontend applications for `author-book-api` and `book-review-api`, respectively. 33 | 34 | If you want to see the complete communication frontend-backend using `GraphQL`, clone the `react-graphql-databases` and follow the README instructions. 35 | 36 | ## Prerequisites 37 | 38 | - [`Java 21+`](https://www.oracle.com/java/technologies/downloads/#java21) 39 | - Some containerization tool [`Docker`](https://www.docker.com), [`Podman`](https://podman.io), etc. 40 | 41 | ## Start Environment 42 | 43 | - Open a terminal and inside `springboot-graphql-databases` root folder run: 44 | ``` 45 | docker compose up -d 46 | ``` 47 | 48 | - Wait for Docker containers to be up and running. To check it, run: 49 | ``` 50 | docker ps -a 51 | ``` 52 | 53 | ## Run applications with Maven 54 | 55 | Inside `springboot-graphql-databases`, run the following Maven commands in different terminals: 56 | 57 | - **author-book-api** 58 | ``` 59 | ./mvnw clean spring-boot:run --projects author-book-api \ 60 | -Dspring-boot.run.jvmArguments="-Dspring.datasource.username=authorbookuser -Dspring.datasource.password=authorbookpass" 61 | ``` 62 | 63 | - **book-review-api** 64 | ``` 65 | ./mvnw clean spring-boot:run --projects book-review-api \ 66 | -Dspring-boot.run.jvmArguments="-Dspring.data.mongodb.username=bookreviewuser -Dspring.data.mongodb.password=bookreviewpass" 67 | ``` 68 | 69 | ## Run Applications as Docker containers 70 | 71 | ### Build Application's Docker Images 72 | 73 | In a terminal and inside `springboot-graphql-databases` root folder, run the following script: 74 | ``` 75 | ./build-docker-images.sh 76 | ``` 77 | 78 | ### Application's environment variables 79 | 80 | - **author-book-api** 81 | 82 | | Environment Variable | Description | 83 | |------------------------|--------------------------------------------------------------------------------------| 84 | | `MYSQL_HOST` | Specify host of the `MySQL` database to use (default `localhost`) | 85 | | `MYSQL_PORT` | Specify port of the `MySQL` database to use (default `3306`) | 86 | | `ZIPKIN_HOST` | Specify host of the `Zipkin` distributed tracing system to use (default `localhost`) | 87 | | `ZIPKIN_PORT` | Specify port of the `Zipkin` distributed tracing system to use (default `9411`) | 88 | | `BOOK_REVIEW_API_HOST` | Specify host of the `book-review-api` service (default `localhost`) | 89 | | `BOOK_REVIEW_API_PORT` | Specify port of the `book-review-api` service (default `9080`) | 90 | 91 | - **book-review-api** 92 | 93 | | Environment Variable | Description | 94 | |----------------------|--------------------------------------------------------------------------------------| 95 | | `MONGODB_HOST` | Specify host of the `MongoDB` database to use (default `localhost`) | 96 | | `MONGODB_PORT` | Specify port of the `MongoDB` database to use (default `27017`) | 97 | | `ZIPKIN_HOST` | Specify host of the `Zipkin` distributed tracing system to use (default `localhost`) | 98 | | `ZIPKIN_PORT` | Specify port of the `Zipkin` distributed tracing system to use (default `9411`) | 99 | 100 | ### Start Applications as Docker containers 101 | 102 | In a terminal and inside `springboot-graphql-databases` root folder, run following script: 103 | ``` 104 | ./start-apps.sh 105 | ``` 106 | 107 | ## Application's Link 108 | 109 | | Application | URL Type | URL | 110 | |-----------------|----------|---------------------------------------| 111 | | author-book-api | Swagger | http://localhost:8080/swagger-ui.html | 112 | | author-book-api | GraphiQL | http://localhost:8080/graphiql | 113 | | book-review-api | GraphiQL | http://localhost:9080/graphiql | 114 | 115 | ## How to use GraphiQL 116 | 117 | - **book-review-api** 118 | 119 | 1. In a browser, access http://localhost:9080/graphiql 120 | 121 | 2. Create a book and return its id: 122 | ``` 123 | mutation { 124 | createBook(bookInput: {title: "Getting Started With Roo", isbn: "9781449307905"}) { 125 | id 126 | } 127 | } 128 | ``` 129 | 130 | 3. Add one review for the book created above, suppose the id is `5bd4bd4790e9f641b7388f23`: 131 | ``` 132 | mutation { 133 | addBookReview(bookId: "5bd4bd4790e9f641b7388f23", reviewInput: {reviewer: "Ivan Franchin", comment: "It is a very good book", rating: 5}) { 134 | id 135 | } 136 | } 137 | ``` 138 | 139 | 4. Get all books stored in `book-review-api`, including their reviews: 140 | ``` 141 | { 142 | getBooks { 143 | id 144 | title 145 | isbn 146 | reviews { 147 | comment 148 | rating 149 | reviewer 150 | createdAt 151 | } 152 | } 153 | } 154 | ``` 155 | 156 | - **author-book-api** 157 | 158 | 1. In a browser, access http://localhost:8080/graphiql 159 | 160 | 2. Create an author and return the author id: 161 | ``` 162 | mutation { 163 | createAuthor(authorInput: {name: "Josh Long"}) { 164 | id 165 | } 166 | } 167 | ``` 168 | 169 | 3. Create a book and return the book id and author name: 170 | > **Note**: while creating this book in `author-book-api`, we are setting the same ISBN, `9781449307905`, as we did when creating the book in `book-review-api`. 171 | ``` 172 | mutation { 173 | createBook(bookInput: {authorId: 1, isbn: "9781449307905", title: "Getting Started With Roo", year: 2020}) { 174 | id 175 | author { 176 | name 177 | } 178 | } 179 | } 180 | ``` 181 | 182 | 4. Get author by id and return some information about his/her books including book reviews from `book-review-api`: 183 | > **Note**: as the book stored in `author-book-api` and `book-review-api` has the same ISBN, `9781449307905`, it's possible to retrieve the reviews of the book. Otherwise, an empty list will be returned in case `book-review-api` does not have a specific ISBN or the service is down. 184 | ``` 185 | { 186 | getAuthorById(authorId: 1) { 187 | name 188 | books { 189 | isbn 190 | title 191 | bookReview { 192 | reviews { 193 | reviewer 194 | rating 195 | comment 196 | createdAt 197 | } 198 | } 199 | } 200 | } 201 | } 202 | ``` 203 | 204 | 5. Update book title and return its id and new title: 205 | ``` 206 | mutation { 207 | updateBook(bookId: 1, bookInput: {title: "Getting Started With Roo 2"}) { 208 | id 209 | title 210 | } 211 | } 212 | ``` 213 | 214 | 6. Delete the author and return author id: 215 | ``` 216 | mutation { 217 | deleteAuthor(authorId: 1) { 218 | id 219 | } 220 | } 221 | ``` 222 | 223 | ## Useful links & commands 224 | 225 | - **Zipkin** 226 | 227 | It can be accessed at http://localhost:9411 228 | 229 | - **MySQL monitor** 230 | ``` 231 | docker exec -it -e MYSQL_PWD=authorbookpass mysql mysql -uauthorbookuser --database authorbookdb 232 | SHOW tables; 233 | SELECT * FROM authors; 234 | SELECT * FROM books; 235 | ``` 236 | > Type `exit` to get out of MySQL monitor 237 | 238 | - **MongoDB shell** 239 | ``` 240 | docker exec -it mongodb mongosh -u bookreviewuser -p bookreviewpass --authenticationDatabase bookreviewdb 241 | use bookreviewdb; 242 | db.books.find().pretty(); 243 | ``` 244 | > Type `exit` to get out of MongoDB shell 245 | 246 | ## Shutdown 247 | 248 | - To stop applications: 249 | - If they were started with `Maven`, go to the terminals where they are running and press `Ctrl+C`; 250 | - If they were started as a Docker container, go to a terminal and, inside `springboot-graphql-databases` root folder, run the script below: 251 | ``` 252 | ./stop-apps.sh 253 | ``` 254 | - To stop and remove docker compose containers, network and volumes, go to a terminal and, inside `springboot-graphql-databases` root folder, run the following command: 255 | ``` 256 | docker compose down -v 257 | ``` 258 | 259 | ## Cleanup 260 | 261 | To remove the Docker images created by this project, go to a terminal and, inside `springboot-graphql-databases` root folder, run the following script: 262 | ``` 263 | ./remove-docker-images.sh 264 | ``` 265 | 266 | ## References 267 | 268 | - https://graphql.org/learn 269 | - https://www.pluralsight.com/resources/blog/guides/building-a-graphql-server-with-spring-boot 270 | - https://www.baeldung.com/spring-graphql 271 | -------------------------------------------------------------------------------- /author-book-api/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.ivanfranchin 7 | springboot-graphql-databases 8 | 1.0.0 9 | ../pom.xml 10 | 11 | author-book-api 12 | author-book-api 13 | Demo project for Spring Boot 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 2024.0.0 29 | 2.7.0 30 | 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-actuator 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-data-jpa 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-graphql 43 | 44 | 45 | org.springframework.boot 46 | spring-boot-starter-validation 47 | 48 | 49 | org.springframework.boot 50 | spring-boot-starter-web 51 | 52 | 53 | io.micrometer 54 | micrometer-tracing-bridge-brave 55 | 56 | 57 | io.zipkin.reporter2 58 | zipkin-reporter-brave 59 | 60 | 61 | org.springframework.cloud 62 | spring-cloud-starter-circuitbreaker-resilience4j 63 | 64 | 65 | org.springframework.cloud 66 | spring-cloud-starter-openfeign 67 | 68 | 69 | 70 | 71 | org.springdoc 72 | springdoc-openapi-starter-webmvc-ui 73 | ${springdoc-openapi.version} 74 | 75 | 76 | 77 | 78 | com.google.code.gson 79 | gson 80 | 81 | 82 | 83 | com.mysql 84 | mysql-connector-j 85 | runtime 86 | 87 | 88 | org.projectlombok 89 | lombok 90 | true 91 | 92 | 93 | org.springframework.boot 94 | spring-boot-starter-test 95 | test 96 | 97 | 98 | org.springframework 99 | spring-webflux 100 | test 101 | 102 | 103 | org.springframework.graphql 104 | spring-graphql-test 105 | test 106 | 107 | 108 | 109 | 110 | 111 | org.springframework.cloud 112 | spring-cloud-dependencies 113 | ${spring-cloud.version} 114 | pom 115 | import 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | org.apache.maven.plugins 124 | maven-compiler-plugin 125 | 126 | 127 | 128 | org.projectlombok 129 | lombok 130 | 131 | 132 | 133 | 134 | 135 | org.springframework.boot 136 | spring-boot-maven-plugin 137 | 138 | 139 | 140 | org.projectlombok 141 | lombok 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/AuthorBookApiApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.cloud.openfeign.EnableFeignClients; 6 | 7 | @EnableFeignClients 8 | @SpringBootApplication 9 | public class AuthorBookApiApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(AuthorBookApiApplication.class, args); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/client/BookReviewApiClient.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.client; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.springframework.cloud.openfeign.FallbackFactory; 6 | import org.springframework.cloud.openfeign.FeignClient; 7 | import org.springframework.stereotype.Component; 8 | import org.springframework.web.bind.annotation.PostMapping; 9 | import org.springframework.web.bind.annotation.RequestBody; 10 | 11 | @FeignClient( 12 | name = "book-review-api", 13 | url = "http://${BOOK_REVIEW_API_HOST:localhost}:${BOOK_REVIEW_API_PORT:9080}", 14 | fallbackFactory = BookReviewApiClient.BookReviewApiClientFallbackFactory.class) 15 | public interface BookReviewApiClient { 16 | 17 | @PostMapping(path = "/graphql", consumes = "application/json") 18 | BookReviewApiResult getBookReviews(@RequestBody String graphQLQuery); 19 | 20 | // ---------------- 21 | // Fallback Factory 22 | 23 | @Component 24 | class BookReviewApiClientFallbackFactory implements FallbackFactory { 25 | 26 | @Override 27 | public BookReviewApiClient create(Throwable throwable) { 28 | return new BookReviewApiClientFallback(throwable); 29 | } 30 | } 31 | 32 | // -------------- 33 | // Client Fallback 34 | 35 | @Slf4j 36 | @RequiredArgsConstructor 37 | class BookReviewApiClientFallback implements BookReviewApiClient { 38 | 39 | private final Throwable cause; 40 | 41 | @Override 42 | public BookReviewApiResult getBookReviews(String graphQLQuery) { 43 | String error = String.format("Unable to access book-review-api. Cause: %s", cause.getMessage()); 44 | log.error(error); 45 | return BookReviewApiResult.empty(error); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/client/BookReviewApiQueryBuilder.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.client; 2 | 3 | import com.google.gson.Gson; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.stereotype.Component; 6 | 7 | @RequiredArgsConstructor 8 | @Component 9 | public class BookReviewApiQueryBuilder { 10 | 11 | private static final String BOOK_REVIEW_QUERY = 12 | "{ getBookByIsbn(bookIsbn: \"%s\") { id, reviews { comment, rating, reviewer, createdAt } } }"; 13 | 14 | private final Gson gson; 15 | 16 | public String getBookReviewQuery(String bookIsbn) { 17 | String query = String.format(BOOK_REVIEW_QUERY, bookIsbn); 18 | BookReviewApiQuery bookReviewApiQuery = new BookReviewApiQuery(query); 19 | return gson.toJson(bookReviewApiQuery); 20 | } 21 | 22 | private record BookReviewApiQuery(String query) { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/client/BookReviewApiResult.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.client; 2 | 3 | import com.ivanfranchin.authorbookapi.model.BookReview; 4 | import com.ivanfranchin.authorbookapi.model.Review; 5 | 6 | import java.util.Collections; 7 | import java.util.List; 8 | 9 | public record BookReviewApiResult(ResultData data, String error) { 10 | 11 | public record ResultData(QueryName getBookByIsbn) { 12 | 13 | public ResultData() { 14 | this(null); 15 | } 16 | 17 | public record QueryName(String id, List reviews) { 18 | } 19 | } 20 | 21 | static BookReviewApiResult empty(String error) { 22 | return new BookReviewApiResult(new ResultData(), error); 23 | } 24 | 25 | public BookReview toBookReview() { 26 | BookReviewApiResult.ResultData.QueryName getBookByIsbn = this.data().getBookByIsbn(); 27 | if (getBookByIsbn == null) { 28 | String errorStr = this.error() != null ? 29 | this.error() : "Unable to get book reviews. Check if there is a book with exact ISBN in book-review-api."; 30 | return new BookReview(errorStr, null, Collections.emptyList()); 31 | } 32 | return new BookReview(null, getBookByIsbn.id(), getBookByIsbn.reviews()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/config/GsonConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.config; 2 | 3 | import com.google.gson.Gson; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | @Configuration 8 | public class GsonConfig { 9 | 10 | @Bean 11 | Gson gson() { 12 | return new Gson(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/exception/AuthorNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.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 AuthorNotFoundException extends RuntimeException { 8 | 9 | public AuthorNotFoundException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/exception/BookDuplicatedIsbnException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.CONFLICT) 7 | public class BookDuplicatedIsbnException extends RuntimeException { 8 | 9 | public BookDuplicatedIsbnException(String message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/exception/BookNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.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 message) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/graphql/AuthorBookExceptionResolver.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.graphql; 2 | 3 | import com.ivanfranchin.authorbookapi.exception.AuthorNotFoundException; 4 | import com.ivanfranchin.authorbookapi.exception.BookDuplicatedIsbnException; 5 | import com.ivanfranchin.authorbookapi.exception.BookNotFoundException; 6 | import graphql.GraphQLError; 7 | import graphql.GraphqlErrorBuilder; 8 | import graphql.schema.DataFetchingEnvironment; 9 | import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; 10 | import org.springframework.graphql.execution.ErrorType; 11 | import org.springframework.stereotype.Component; 12 | 13 | @Component 14 | public class AuthorBookExceptionResolver extends DataFetcherExceptionResolverAdapter { 15 | 16 | @Override 17 | protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { 18 | ErrorType errorType = ErrorType.INTERNAL_ERROR; 19 | if (ex instanceof BookNotFoundException || ex instanceof AuthorNotFoundException) { 20 | errorType = ErrorType.NOT_FOUND; 21 | } else if (ex instanceof BookDuplicatedIsbnException) { 22 | errorType = ErrorType.BAD_REQUEST; 23 | } 24 | 25 | return GraphqlErrorBuilder.newError(env) 26 | .message("Resolved error: " + ex.getMessage()) 27 | .errorType(errorType) 28 | .build(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/graphql/AuthorController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.graphql; 2 | 3 | import com.ivanfranchin.authorbookapi.graphql.input.AuthorInput; 4 | import com.ivanfranchin.authorbookapi.model.Author; 5 | import com.ivanfranchin.authorbookapi.service.AuthorService; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.graphql.data.method.annotation.Argument; 8 | import org.springframework.graphql.data.method.annotation.MutationMapping; 9 | import org.springframework.graphql.data.method.annotation.QueryMapping; 10 | import org.springframework.stereotype.Controller; 11 | 12 | import java.util.List; 13 | 14 | @RequiredArgsConstructor 15 | @Controller("GraphQlAuthorController") 16 | public class AuthorController { 17 | 18 | private final AuthorService authorService; 19 | 20 | @QueryMapping 21 | public List getAuthors() { 22 | return authorService.getAuthors(); 23 | } 24 | 25 | @QueryMapping 26 | public Author getAuthorById(@Argument Long authorId) { 27 | return authorService.validateAndGetAuthorById(authorId); 28 | } 29 | 30 | @QueryMapping 31 | public List getAuthorByName(@Argument String authorName) { 32 | return authorService.validateAndGetAuthorByName(authorName); 33 | } 34 | 35 | @MutationMapping 36 | public Author createAuthor(@Argument AuthorInput authorInput) { 37 | Author author = Author.from(authorInput); 38 | return authorService.saveAuthor(author); 39 | } 40 | 41 | @MutationMapping 42 | public Author updateAuthor(@Argument Long authorId, @Argument AuthorInput authorInput) { 43 | Author author = authorService.validateAndGetAuthorById(authorId); 44 | Author.updateFrom(authorInput, author); 45 | return authorService.saveAuthor(author); 46 | } 47 | 48 | @MutationMapping 49 | public Author deleteAuthor(@Argument Long authorId) { 50 | Author author = authorService.validateAndGetAuthorById(authorId); 51 | authorService.deleteAuthor(author); 52 | return author; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/graphql/BookController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.graphql; 2 | 3 | import com.ivanfranchin.authorbookapi.client.BookReviewApiClient; 4 | import com.ivanfranchin.authorbookapi.client.BookReviewApiQueryBuilder; 5 | import com.ivanfranchin.authorbookapi.graphql.input.BookInput; 6 | import com.ivanfranchin.authorbookapi.model.Author; 7 | import com.ivanfranchin.authorbookapi.model.Book; 8 | import com.ivanfranchin.authorbookapi.model.BookReview; 9 | import com.ivanfranchin.authorbookapi.service.AuthorService; 10 | import com.ivanfranchin.authorbookapi.service.BookService; 11 | import lombok.RequiredArgsConstructor; 12 | import org.springframework.graphql.data.method.annotation.Argument; 13 | import org.springframework.graphql.data.method.annotation.MutationMapping; 14 | import org.springframework.graphql.data.method.annotation.QueryMapping; 15 | import org.springframework.graphql.data.method.annotation.SchemaMapping; 16 | import org.springframework.stereotype.Controller; 17 | 18 | import java.util.List; 19 | 20 | @RequiredArgsConstructor 21 | @Controller("GraphQlBookController") 22 | public class BookController { 23 | 24 | private final AuthorService authorService; 25 | private final BookService bookService; 26 | private final BookReviewApiQueryBuilder bookReviewApiQueryBuilder; 27 | private final BookReviewApiClient bookReviewApiClient; 28 | 29 | @QueryMapping 30 | public List getBooks() { 31 | return bookService.getBooks(); 32 | } 33 | 34 | @QueryMapping 35 | public Book getBookById(@Argument Long bookId) { 36 | return bookService.validateAndGetBookById(bookId); 37 | } 38 | 39 | @MutationMapping 40 | public Book createBook(@Argument BookInput bookInput) { 41 | Author author = authorService.validateAndGetAuthorById(bookInput.authorId()); 42 | Book book = Book.from(bookInput); 43 | book.setAuthor(author); 44 | return bookService.saveBook(book); 45 | } 46 | 47 | @MutationMapping 48 | public Book updateBook(@Argument Long bookId, @Argument BookInput bookInput) { 49 | Book book = bookService.validateAndGetBookById(bookId); 50 | Book.updateFrom(bookInput, book); 51 | 52 | Long authorId = bookInput.authorId(); 53 | if (authorId != null) { 54 | Author author = authorService.validateAndGetAuthorById(authorId); 55 | book.setAuthor(author); 56 | } 57 | return bookService.saveBook(book); 58 | } 59 | 60 | @MutationMapping 61 | public Book deleteBook(@Argument Long bookId) { 62 | Book book = bookService.validateAndGetBookById(bookId); 63 | bookService.deleteBook(book); 64 | return book; 65 | } 66 | 67 | @SchemaMapping(field = "bookReview") 68 | public BookReview getBookReview(Book book) { 69 | String graphQLQuery = bookReviewApiQueryBuilder.getBookReviewQuery(book.getIsbn()); 70 | return bookReviewApiClient.getBookReviews(graphQLQuery).toBookReview(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/graphql/input/AuthorInput.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.graphql.input; 2 | 3 | public record AuthorInput(String name) { 4 | } 5 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/graphql/input/BookInput.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.graphql.input; 2 | 3 | public record BookInput(String isbn, String title, Integer year, Long authorId) { 4 | } 5 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/model/Author.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.model; 2 | 3 | import com.ivanfranchin.authorbookapi.graphql.input.AuthorInput; 4 | import com.ivanfranchin.authorbookapi.restapi.dto.CreateAuthorRequest; 5 | import com.ivanfranchin.authorbookapi.restapi.dto.UpdateAuthorRequest; 6 | import jakarta.persistence.CascadeType; 7 | import jakarta.persistence.Column; 8 | import jakarta.persistence.Entity; 9 | import jakarta.persistence.GeneratedValue; 10 | import jakarta.persistence.GenerationType; 11 | import jakarta.persistence.Id; 12 | import jakarta.persistence.OneToMany; 13 | import jakarta.persistence.PrePersist; 14 | import jakarta.persistence.PreUpdate; 15 | import jakarta.persistence.Table; 16 | import lombok.Data; 17 | import lombok.EqualsAndHashCode; 18 | import lombok.ToString; 19 | 20 | import java.time.Instant; 21 | import java.util.LinkedHashSet; 22 | import java.util.Set; 23 | 24 | @Data 25 | @ToString(exclude = "books") 26 | @EqualsAndHashCode(exclude = "books") 27 | @Entity 28 | @Table(name = "authors") 29 | public class Author { 30 | 31 | @Id 32 | @GeneratedValue(strategy = GenerationType.IDENTITY) 33 | private Long id; 34 | 35 | @OneToMany(mappedBy = "author", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) 36 | private Set books = new LinkedHashSet<>(); 37 | 38 | @Column(nullable = false) 39 | private String name; 40 | 41 | private Instant createdAt; 42 | private Instant updatedAt; 43 | 44 | @PrePersist 45 | public void onPrePersist() { 46 | createdAt = updatedAt = Instant.now(); 47 | } 48 | 49 | @PreUpdate 50 | public void onPreUpdate() { 51 | updatedAt = Instant.now(); 52 | } 53 | 54 | public static Author from(AuthorInput authorInput) { 55 | Author author = new Author(); 56 | author.setName(authorInput.name()); 57 | return author; 58 | } 59 | 60 | public static void updateFrom(AuthorInput authorInput, Author author) { 61 | if (authorInput.name() != null) { 62 | author.setName(authorInput.name()); 63 | } 64 | } 65 | 66 | public static Author from(CreateAuthorRequest createAuthorRequest) { 67 | Author author = new Author(); 68 | author.setName(createAuthorRequest.name()); 69 | return author; 70 | } 71 | 72 | public static void updateFrom(UpdateAuthorRequest updateAuthorRequest, Author author) { 73 | if (updateAuthorRequest.name() != null) { 74 | author.setName(updateAuthorRequest.name()); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/model/Book.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.model; 2 | 3 | import com.ivanfranchin.authorbookapi.graphql.input.BookInput; 4 | import com.ivanfranchin.authorbookapi.restapi.dto.CreateBookRequest; 5 | import com.ivanfranchin.authorbookapi.restapi.dto.UpdateBookRequest; 6 | import jakarta.persistence.Column; 7 | import jakarta.persistence.Entity; 8 | import jakarta.persistence.FetchType; 9 | import jakarta.persistence.ForeignKey; 10 | import jakarta.persistence.GeneratedValue; 11 | import jakarta.persistence.GenerationType; 12 | import jakarta.persistence.Id; 13 | import jakarta.persistence.JoinColumn; 14 | import jakarta.persistence.ManyToOne; 15 | import jakarta.persistence.PrePersist; 16 | import jakarta.persistence.PreUpdate; 17 | import jakarta.persistence.Table; 18 | import jakarta.persistence.Transient; 19 | import lombok.Data; 20 | import lombok.EqualsAndHashCode; 21 | import lombok.ToString; 22 | 23 | import java.time.Instant; 24 | 25 | @Data 26 | @ToString(exclude = "author") 27 | @EqualsAndHashCode(exclude = "author") 28 | @Entity 29 | @Table(name = "books") 30 | public class Book { 31 | 32 | @Id 33 | @GeneratedValue(strategy = GenerationType.IDENTITY) 34 | private Long id; 35 | 36 | @ManyToOne(fetch = FetchType.LAZY) 37 | @JoinColumn(name = "author_id", foreignKey = @ForeignKey(name = "FK_AUTHOR")) 38 | private Author author; 39 | 40 | @Column(nullable = false, unique = true) 41 | private String isbn; 42 | 43 | @Column(nullable = false) 44 | private String title; 45 | 46 | @Column(nullable = false) 47 | private Integer year; 48 | 49 | @Transient 50 | private BookReview reviewRes; 51 | 52 | private Instant createdAt; 53 | private Instant updatedAt; 54 | 55 | @PrePersist 56 | public void onPrePersist() { 57 | createdAt = updatedAt = Instant.now(); 58 | } 59 | 60 | @PreUpdate 61 | public void onPreUpdate() { 62 | updatedAt = Instant.now(); 63 | } 64 | 65 | public static Book from(BookInput bookInput) { 66 | Book book = new Book(); 67 | book.setIsbn(bookInput.isbn()); 68 | book.setTitle(bookInput.title()); 69 | book.setYear(bookInput.year()); 70 | return book; 71 | } 72 | 73 | public static void updateFrom(BookInput bookInput, Book book) { 74 | if (bookInput.isbn() != null) { 75 | book.setIsbn(bookInput.isbn()); 76 | } 77 | if (bookInput.title() != null) { 78 | book.setTitle(bookInput.title()); 79 | } 80 | if (bookInput.year() != null) { 81 | book.setYear(bookInput.year()); 82 | } 83 | } 84 | 85 | public static Book from(CreateBookRequest createBookRequest) { 86 | Book book = new Book(); 87 | book.setIsbn(createBookRequest.isbn()); 88 | book.setTitle(createBookRequest.title()); 89 | book.setYear(createBookRequest.year()); 90 | return book; 91 | } 92 | 93 | public static void updateFrom(UpdateBookRequest updateBookRequest, Book book) { 94 | if (updateBookRequest.isbn() != null) { 95 | book.setIsbn(updateBookRequest.isbn()); 96 | } 97 | if (updateBookRequest.title() != null) { 98 | book.setTitle(updateBookRequest.title()); 99 | } 100 | if (updateBookRequest.year() != null) { 101 | book.setYear(updateBookRequest.year()); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/model/BookReview.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.model; 2 | 3 | import java.util.List; 4 | 5 | public record BookReview(String error, String id, List reviews) { 6 | } 7 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/model/Review.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.model; 2 | 3 | public record Review(String reviewer, String comment, Integer rating, String createdAt) { 4 | } 5 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/repository/AuthorRepository.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.repository; 2 | 3 | import com.ivanfranchin.authorbookapi.model.Author; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.List; 8 | 9 | @Repository 10 | public interface AuthorRepository extends JpaRepository { 11 | 12 | List findByNameContainingOrderByName(String authorName); 13 | } 14 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/repository/BookRepository.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.repository; 2 | 3 | import com.ivanfranchin.authorbookapi.model.Author; 4 | import com.ivanfranchin.authorbookapi.model.Book; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.util.List; 9 | 10 | @Repository 11 | public interface BookRepository extends JpaRepository { 12 | 13 | List findByAuthor(Author author); 14 | } 15 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/AuthorController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi; 2 | 3 | import com.ivanfranchin.authorbookapi.model.Author; 4 | import com.ivanfranchin.authorbookapi.restapi.dto.AuthorResponse; 5 | import com.ivanfranchin.authorbookapi.restapi.dto.BookResponse; 6 | import com.ivanfranchin.authorbookapi.restapi.dto.CreateAuthorRequest; 7 | import com.ivanfranchin.authorbookapi.restapi.dto.UpdateAuthorRequest; 8 | import com.ivanfranchin.authorbookapi.service.AuthorService; 9 | import jakarta.validation.Valid; 10 | import lombok.RequiredArgsConstructor; 11 | import org.springframework.http.HttpStatus; 12 | import org.springframework.web.bind.annotation.DeleteMapping; 13 | import org.springframework.web.bind.annotation.GetMapping; 14 | import org.springframework.web.bind.annotation.PathVariable; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.PutMapping; 17 | import org.springframework.web.bind.annotation.RequestBody; 18 | import org.springframework.web.bind.annotation.RequestMapping; 19 | import org.springframework.web.bind.annotation.ResponseStatus; 20 | import org.springframework.web.bind.annotation.RestController; 21 | 22 | import java.util.List; 23 | import java.util.Set; 24 | import java.util.stream.Collectors; 25 | 26 | @RequiredArgsConstructor 27 | @RestController("RestApiAuthorController") 28 | @RequestMapping("/api/authors") 29 | public class AuthorController { 30 | 31 | private final AuthorService authorService; 32 | 33 | @GetMapping 34 | public List getAuthors() { 35 | return authorService.getAuthors() 36 | .stream() 37 | .map(AuthorResponse::from) 38 | .collect(Collectors.toList()); 39 | } 40 | 41 | @GetMapping("/name/{authorName}") 42 | public List getAuthorByName(@PathVariable String authorName) { 43 | return authorService.validateAndGetAuthorByName(authorName) 44 | .stream() 45 | .map(AuthorResponse::from) 46 | .collect(Collectors.toList()); 47 | } 48 | 49 | @GetMapping("/{authorId}") 50 | public AuthorResponse getAuthorById(@PathVariable Long authorId) { 51 | Author author = authorService.validateAndGetAuthorById(authorId); 52 | return AuthorResponse.from(author); 53 | } 54 | 55 | @ResponseStatus(HttpStatus.CREATED) 56 | @PostMapping 57 | public AuthorResponse createAuthor(@Valid @RequestBody CreateAuthorRequest createAuthorRequest) { 58 | Author author = Author.from(createAuthorRequest); 59 | author = authorService.saveAuthor(author); 60 | return AuthorResponse.from(author); 61 | } 62 | 63 | @PutMapping("/{authorId}") 64 | public AuthorResponse updateAuthor(@PathVariable Long authorId, @Valid @RequestBody UpdateAuthorRequest updateAuthorRequest) { 65 | Author author = authorService.validateAndGetAuthorById(authorId); 66 | Author.updateFrom(updateAuthorRequest, author); 67 | author = authorService.saveAuthor(author); 68 | return AuthorResponse.from(author); 69 | } 70 | 71 | @DeleteMapping("/{authorId}") 72 | public AuthorResponse deleteAuthor(@PathVariable Long authorId) { 73 | Author author = authorService.validateAndGetAuthorById(authorId); 74 | authorService.deleteAuthor(author); 75 | return AuthorResponse.from(author); 76 | } 77 | 78 | @GetMapping("/{authorId}/books") 79 | public Set getAuthorBooks(@PathVariable Long authorId) { 80 | Author author = authorService.validateAndGetAuthorById(authorId); 81 | return author.getBooks() 82 | .stream() 83 | .map(BookResponse::from) 84 | .collect(Collectors.toSet()); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/BookController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi; 2 | 3 | import com.ivanfranchin.authorbookapi.client.BookReviewApiClient; 4 | import com.ivanfranchin.authorbookapi.client.BookReviewApiQueryBuilder; 5 | import com.ivanfranchin.authorbookapi.model.Author; 6 | import com.ivanfranchin.authorbookapi.model.Book; 7 | import com.ivanfranchin.authorbookapi.model.BookReview; 8 | import com.ivanfranchin.authorbookapi.restapi.dto.BookResponse; 9 | import com.ivanfranchin.authorbookapi.restapi.dto.CreateBookRequest; 10 | import com.ivanfranchin.authorbookapi.restapi.dto.UpdateBookRequest; 11 | import com.ivanfranchin.authorbookapi.service.AuthorService; 12 | import com.ivanfranchin.authorbookapi.service.BookService; 13 | import jakarta.validation.Valid; 14 | import lombok.RequiredArgsConstructor; 15 | import org.springframework.http.HttpStatus; 16 | import org.springframework.web.bind.annotation.DeleteMapping; 17 | import org.springframework.web.bind.annotation.GetMapping; 18 | import org.springframework.web.bind.annotation.PathVariable; 19 | import org.springframework.web.bind.annotation.PostMapping; 20 | import org.springframework.web.bind.annotation.PutMapping; 21 | import org.springframework.web.bind.annotation.RequestBody; 22 | import org.springframework.web.bind.annotation.RequestMapping; 23 | import org.springframework.web.bind.annotation.ResponseStatus; 24 | import org.springframework.web.bind.annotation.RestController; 25 | 26 | import java.util.List; 27 | import java.util.stream.Collectors; 28 | 29 | @RequiredArgsConstructor 30 | @RestController("RestApiBookController") 31 | @RequestMapping("/api/books") 32 | public class BookController { 33 | 34 | private final BookService bookService; 35 | private final AuthorService authorService; 36 | private final BookReviewApiClient bookReviewApiClient; 37 | private final BookReviewApiQueryBuilder bookReviewApiQueryBuilder; 38 | 39 | @GetMapping 40 | public List getBooks() { 41 | return bookService.getBooks() 42 | .stream() 43 | .map(BookResponse::from) 44 | .collect(Collectors.toList()); 45 | } 46 | 47 | @GetMapping("/{bookId}") 48 | public BookResponse getBook(@PathVariable Long bookId) { 49 | Book book = bookService.validateAndGetBookById(bookId); 50 | return BookResponse.from(book); 51 | } 52 | 53 | @ResponseStatus(HttpStatus.CREATED) 54 | @PostMapping 55 | public BookResponse createBook(@Valid @RequestBody CreateBookRequest createBookRequest) { 56 | Author author = authorService.validateAndGetAuthorById(createBookRequest.authorId()); 57 | Book book = Book.from(createBookRequest); 58 | book.setAuthor(author); 59 | book = bookService.saveBook(book); 60 | return BookResponse.from(book); 61 | } 62 | 63 | @PutMapping("/{bookId}") 64 | public BookResponse updateBook(@PathVariable Long bookId, @Valid @RequestBody UpdateBookRequest updateBookRequest) { 65 | Book book = bookService.validateAndGetBookById(bookId); 66 | Book.updateFrom(updateBookRequest, book); 67 | Long authorId = updateBookRequest.authorId(); 68 | if (authorId != null) { 69 | Author author = authorService.validateAndGetAuthorById(authorId); 70 | book.setAuthor(author); 71 | } 72 | book = bookService.saveBook(book); 73 | return BookResponse.from(book); 74 | } 75 | 76 | @DeleteMapping("/{bookId}") 77 | public BookResponse deleteBook(@PathVariable Long bookId) { 78 | Book book = bookService.validateAndGetBookById(bookId); 79 | bookService.deleteBook(book); 80 | return BookResponse.from(book); 81 | } 82 | 83 | @GetMapping("/{bookId}/reviews") 84 | public BookReview getBookReviews(@PathVariable Long bookId) { 85 | Book book = bookService.validateAndGetBookById(bookId); 86 | String graphQLQuery = bookReviewApiQueryBuilder.getBookReviewQuery(book.getIsbn()); 87 | return bookReviewApiClient.getBookReviews(graphQLQuery).toBookReview(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/config/CorsConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi.config; 2 | 3 | import org.springframework.beans.factory.annotation.Value; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.cors.CorsConfiguration; 7 | import org.springframework.web.cors.CorsConfigurationSource; 8 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 9 | 10 | import java.util.List; 11 | 12 | @Configuration 13 | public class CorsConfig { 14 | 15 | @Bean 16 | CorsConfigurationSource corsConfigurationSource(@Value("${app.cors.allowed-origins}") List allowedOrigins) { 17 | CorsConfiguration configuration = new CorsConfiguration(); 18 | configuration.setAllowCredentials(true); 19 | configuration.setAllowedOriginPatterns(allowedOrigins); 20 | configuration.addAllowedMethod("*"); 21 | configuration.addAllowedHeader("*"); 22 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 23 | source.registerCorsConfiguration("/**", configuration); 24 | return source; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/config/ErrorAttributesConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi.config; 2 | 3 | import org.springframework.boot.web.error.ErrorAttributeOptions; 4 | import org.springframework.boot.web.error.ErrorAttributeOptions.Include; 5 | import org.springframework.boot.web.servlet.error.DefaultErrorAttributes; 6 | import org.springframework.boot.web.servlet.error.ErrorAttributes; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.web.context.request.WebRequest; 10 | 11 | import java.util.Map; 12 | 13 | @Configuration 14 | public class ErrorAttributesConfig { 15 | 16 | @Bean 17 | ErrorAttributes errorAttributes() { 18 | return new DefaultErrorAttributes() { 19 | @Override 20 | public Map getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) { 21 | return super.getErrorAttributes(webRequest, 22 | options.including(Include.EXCEPTION, Include.MESSAGE, Include.BINDING_ERRORS)); 23 | } 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/config/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi.config; 2 | 3 | import io.swagger.v3.oas.models.Components; 4 | import io.swagger.v3.oas.models.OpenAPI; 5 | import io.swagger.v3.oas.models.info.Info; 6 | import org.springdoc.core.models.GroupedOpenApi; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | 11 | @Configuration 12 | public class SwaggerConfig { 13 | 14 | @Value("${spring.application.name}") 15 | private String applicationName; 16 | 17 | @Bean 18 | OpenAPI customOpenAPI() { 19 | return new OpenAPI().components(new Components()).info(new Info().title(applicationName)); 20 | } 21 | 22 | @Bean 23 | GroupedOpenApi customApi() { 24 | return GroupedOpenApi.builder().group("api").pathsToMatch("/api/**").build(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/dto/AuthorResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi.dto; 2 | 3 | import com.ivanfranchin.authorbookapi.model.Author; 4 | 5 | public record AuthorResponse(Long id, String name) { 6 | 7 | public static AuthorResponse from(Author author) { 8 | return new AuthorResponse(author.getId(), author.getName()); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/dto/BookResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi.dto; 2 | 3 | import com.ivanfranchin.authorbookapi.model.Book; 4 | 5 | public record BookResponse(Long id, String isbn, String title, Integer year) { 6 | 7 | public static BookResponse from(Book book) { 8 | return new BookResponse(book.getId(), book.getIsbn(), book.getTitle(), book.getYear()); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/dto/CreateAuthorRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | public record CreateAuthorRequest(@Schema(example = "Craig Walls") @NotBlank String name) { 7 | } 8 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/dto/CreateBookRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.NotNull; 6 | 7 | public record CreateBookRequest( 8 | @Schema(example = "1") @NotNull Long authorId, 9 | @Schema(example = "9781617292545") @NotBlank String isbn, 10 | @Schema(example = "Spring Boot in Action") @NotBlank String title, 11 | @Schema(example = "2016") @NotNull Integer year) { 12 | } 13 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/dto/UpdateAuthorRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | 5 | public record UpdateAuthorRequest(@Schema(example = "Ivan Franchin") String name) { 6 | } 7 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/restapi/dto/UpdateBookRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.restapi.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | 5 | public record UpdateBookRequest( 6 | @Schema(example = "2") Long authorId, 7 | @Schema(example = "9781617291999") String isbn, 8 | @Schema(example = "Java 8 in Action") String title, 9 | @Schema(example = "2014") Integer year) { 10 | } 11 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/service/AuthorService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.service; 2 | 3 | import com.ivanfranchin.authorbookapi.model.Author; 4 | 5 | import java.util.List; 6 | 7 | public interface AuthorService { 8 | 9 | List getAuthors(); 10 | 11 | Author validateAndGetAuthorById(Long id); 12 | 13 | List validateAndGetAuthorByName(String name); 14 | 15 | Author saveAuthor(Author author); 16 | 17 | void deleteAuthor(Author author); 18 | } 19 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/service/AuthorServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.service; 2 | 3 | import com.ivanfranchin.authorbookapi.exception.AuthorNotFoundException; 4 | import com.ivanfranchin.authorbookapi.model.Author; 5 | import com.ivanfranchin.authorbookapi.repository.AuthorRepository; 6 | import lombok.RequiredArgsConstructor; 7 | import org.apache.commons.lang3.StringUtils; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.util.ArrayList; 11 | import java.util.List; 12 | 13 | @RequiredArgsConstructor 14 | @Service 15 | public class AuthorServiceImpl implements AuthorService { 16 | 17 | private final AuthorRepository authorRepository; 18 | 19 | @Override 20 | public List getAuthors() { 21 | return authorRepository.findAll(); 22 | } 23 | 24 | @Override 25 | public Author validateAndGetAuthorById(Long id) { 26 | return authorRepository.findById(id) 27 | .orElseThrow(() -> new AuthorNotFoundException(String.format("Author with id '%s' not found", id))); 28 | } 29 | 30 | @Override 31 | public List validateAndGetAuthorByName(String name) { 32 | return new ArrayList<>(authorRepository.findByNameContainingOrderByName(StringUtils.normalizeSpace(name))); 33 | } 34 | 35 | @Override 36 | public Author saveAuthor(Author author) { 37 | return authorRepository.save(author); 38 | } 39 | 40 | @Override 41 | public void deleteAuthor(Author author) { 42 | authorRepository.delete(author); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/service/BookService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.service; 2 | 3 | import com.ivanfranchin.authorbookapi.model.Author; 4 | import com.ivanfranchin.authorbookapi.model.Book; 5 | 6 | import java.util.List; 7 | 8 | public interface BookService { 9 | 10 | List getBooks(); 11 | 12 | List getBooksByAuthor(Author author); 13 | 14 | Book validateAndGetBookById(Long id); 15 | 16 | Book saveBook(Book book); 17 | 18 | void deleteBook(Book book); 19 | } 20 | -------------------------------------------------------------------------------- /author-book-api/src/main/java/com/ivanfranchin/authorbookapi/service/BookServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi.service; 2 | 3 | import com.ivanfranchin.authorbookapi.exception.BookDuplicatedIsbnException; 4 | import com.ivanfranchin.authorbookapi.exception.BookNotFoundException; 5 | import com.ivanfranchin.authorbookapi.model.Author; 6 | import com.ivanfranchin.authorbookapi.model.Book; 7 | import com.ivanfranchin.authorbookapi.repository.BookRepository; 8 | import lombok.RequiredArgsConstructor; 9 | import org.springframework.dao.DataIntegrityViolationException; 10 | import org.springframework.stereotype.Service; 11 | 12 | import java.util.List; 13 | 14 | @RequiredArgsConstructor 15 | @Service 16 | public class BookServiceImpl implements BookService { 17 | 18 | private final BookRepository bookRepository; 19 | 20 | @Override 21 | public List getBooks() { 22 | return bookRepository.findAll(); 23 | } 24 | 25 | @Override 26 | public List getBooksByAuthor(Author author) { 27 | return bookRepository.findByAuthor(author); 28 | } 29 | 30 | @Override 31 | public Book validateAndGetBookById(Long id) { 32 | return bookRepository.findById(id).orElseThrow(() -> new BookNotFoundException(String.format("Book with id '%s' not found", id))); 33 | } 34 | 35 | @Override 36 | public Book saveBook(Book book) { 37 | try { 38 | return bookRepository.save(book); 39 | } catch (DataIntegrityViolationException e) { 40 | throw new BookDuplicatedIsbnException(String.format("Book with ISBN '%s' already exists", book.getIsbn())); 41 | } 42 | } 43 | 44 | @Override 45 | public void deleteBook(Book book) { 46 | bookRepository.delete(book); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /author-book-api/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: author-book-api 4 | jpa: 5 | hibernate: 6 | ddl-auto: update 7 | datasource: 8 | url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/authorbookdb?characterEncoding=UTF-8&serverTimezone=UTC 9 | username: change-me 10 | password: change-me 11 | graphql: 12 | graphiql: 13 | enabled: true 14 | cors: 15 | allowed-origins: ${app.cors.allowed-origins} 16 | cloud: 17 | openfeign: 18 | circuitbreaker: 19 | enabled: true 20 | client: 21 | config: 22 | default: 23 | loggerLevel: BASIC 24 | 25 | app: 26 | cors: 27 | allowed-origins: http://localhost:3000 28 | 29 | management: 30 | tracing: 31 | sampling: 32 | probability: 1.0 33 | zipkin: 34 | tracing: 35 | endpoint: http://${ZIPKIN_HOST:localhost}:${ZIPKIN_PORT:9411}/api/v2/spans 36 | 37 | springdoc: 38 | swagger-ui: 39 | disable-swagger-default-url: true 40 | 41 | logging: 42 | level: 43 | org.hibernate: 44 | SQL: DEBUG 45 | type.descriptor.sql.BasicBinder: TRACE 46 | com.ivanfranchin.authorbookapi.client.BookReviewApiClient: DEBUG 47 | -------------------------------------------------------------------------------- /author-book-api/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ _ _ _ _ 2 | __ _ _ _| |_| |__ ___ _ __ | |__ ___ ___ | | __ __ _ _ __ (_) 3 | / _` | | | | __| '_ \ / _ \| '__|____| '_ \ / _ \ / _ \| |/ /____ / _` | '_ \| | 4 | | (_| | |_| | |_| | | | (_) | | |_____| |_) | (_) | (_) | <_____| (_| | |_) | | 5 | \__,_|\__,_|\__|_| |_|\___/|_| |_.__/ \___/ \___/|_|\_\ \__,_| .__/|_| 6 | |_| 7 | :: Spring Boot :: ${spring-boot.formatted-version} 8 | -------------------------------------------------------------------------------- /author-book-api/src/main/resources/graphql/author-book.graphqls: -------------------------------------------------------------------------------- 1 | # ------ 2 | # Author 3 | 4 | type Author { 5 | id: ID! 6 | name: String! 7 | books: [Book!]! 8 | } 9 | 10 | input CreateAuthorInput { 11 | name: String! 12 | } 13 | 14 | input UpdateAuthorInput { 15 | name: String 16 | } 17 | 18 | # ---- 19 | # Book 20 | 21 | type Book { 22 | id: ID! 23 | isbn: String! 24 | title: String! 25 | year: Int! 26 | author: Author! 27 | bookReview: BookReview! 28 | } 29 | 30 | input CreateBookInput { 31 | authorId: Int! 32 | isbn: String! 33 | title: String! 34 | year: Int! 35 | } 36 | 37 | input UpdateBookInput { 38 | authorId: Int 39 | isbn: String 40 | title: String 41 | year: Int 42 | } 43 | 44 | # ---------- 45 | # BookReview 46 | 47 | type BookReview { 48 | error: String 49 | id: String 50 | reviews: [Review!]! 51 | } 52 | 53 | # ------ 54 | # Review 55 | 56 | type Review { 57 | reviewer: String! 58 | comment: String! 59 | rating: Int! 60 | createdAt: String! 61 | } 62 | 63 | # --- 64 | 65 | type Query { 66 | getAuthors: [Author!]! 67 | getAuthorById(authorId: ID!): Author 68 | getAuthorByName(authorName: String!): [Author!]! 69 | 70 | getBooks: [Book!]! 71 | getBookById(bookId: ID!): Book 72 | } 73 | 74 | # --- 75 | 76 | type Mutation { 77 | createAuthor(authorInput: CreateAuthorInput!): Author! 78 | updateAuthor(authorId: ID!, authorInput: UpdateAuthorInput!): Author! 79 | deleteAuthor(authorId: ID!): Author! 80 | 81 | createBook(bookInput: CreateBookInput!): Book! 82 | updateBook(bookId: ID!, bookInput: UpdateBookInput!): Book! 83 | deleteBook(bookId: ID!): Book! 84 | } -------------------------------------------------------------------------------- /author-book-api/src/test/java/com/ivanfranchin/authorbookapi/AuthorBookApiApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.authorbookapi; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | @Disabled 8 | @SpringBootTest 9 | class AuthorBookApiApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /book-review-api/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.ivanfranchin 7 | springboot-graphql-databases 8 | 1.0.0 9 | ../pom.xml 10 | 11 | book-review-api 12 | book-review-api 13 | Demo project for Spring Boot 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-actuator 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-data-mongodb 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-graphql 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-web 43 | 44 | 45 | io.micrometer 46 | micrometer-tracing-bridge-brave 47 | 48 | 49 | io.zipkin.reporter2 50 | zipkin-reporter-brave 51 | 52 | 53 | 54 | org.projectlombok 55 | lombok 56 | true 57 | 58 | 59 | org.springframework.boot 60 | spring-boot-starter-test 61 | test 62 | 63 | 64 | org.springframework 65 | spring-webflux 66 | test 67 | 68 | 69 | org.springframework.graphql 70 | spring-graphql-test 71 | test 72 | 73 | 74 | 75 | 76 | 77 | 78 | org.apache.maven.plugins 79 | maven-compiler-plugin 80 | 81 | 82 | 83 | org.projectlombok 84 | lombok 85 | 86 | 87 | 88 | 89 | 90 | org.springframework.boot 91 | spring-boot-maven-plugin 92 | 93 | 94 | 95 | org.projectlombok 96 | lombok 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/BookReviewApiApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class BookReviewApiApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(BookReviewApiApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/config/CorsConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.config; 2 | 3 | import org.springframework.beans.factory.annotation.Value; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.cors.CorsConfiguration; 7 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 8 | import org.springframework.web.filter.CorsFilter; 9 | 10 | import java.util.List; 11 | 12 | @Configuration 13 | public class CorsConfig { 14 | 15 | @Bean 16 | CorsFilter corsFilter(@Value("${app.cors.allowed-origins}") List allowedOrigins) { 17 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 18 | CorsConfiguration config = new CorsConfiguration(); 19 | config.setAllowCredentials(true); 20 | config.setAllowedOriginPatterns(allowedOrigins); 21 | config.addAllowedMethod("*"); 22 | config.addAllowedHeader("*"); 23 | source.registerCorsConfiguration("/**", config); 24 | return new CorsFilter(source); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/config/MongoConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.data.mongodb.config.EnableMongoAuditing; 5 | 6 | @EnableMongoAuditing 7 | @Configuration 8 | public class MongoConfig { 9 | } 10 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/exception/BookDuplicatedIsbnException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.exception; 2 | 3 | public class BookDuplicatedIsbnException extends RuntimeException { 4 | 5 | public BookDuplicatedIsbnException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/exception/BookNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.exception; 2 | 3 | public class BookNotFoundException extends RuntimeException { 4 | 5 | public BookNotFoundException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/graphql/BookReviewController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.graphql; 2 | 3 | import com.ivanfranchin.bookreviewapi.graphql.input.BookInput; 4 | import com.ivanfranchin.bookreviewapi.graphql.input.ReviewInput; 5 | import com.ivanfranchin.bookreviewapi.model.Book; 6 | import com.ivanfranchin.bookreviewapi.model.Review; 7 | import com.ivanfranchin.bookreviewapi.service.BookService; 8 | import lombok.RequiredArgsConstructor; 9 | import org.springframework.graphql.data.method.annotation.Argument; 10 | import org.springframework.graphql.data.method.annotation.MutationMapping; 11 | import org.springframework.graphql.data.method.annotation.QueryMapping; 12 | import org.springframework.stereotype.Controller; 13 | 14 | import java.util.List; 15 | 16 | @RequiredArgsConstructor 17 | @Controller 18 | public class BookReviewController { 19 | 20 | private final BookService bookService; 21 | 22 | @QueryMapping 23 | public List getBooks() { 24 | return bookService.getBooks(); 25 | } 26 | 27 | @QueryMapping 28 | public Book getBookById(@Argument String bookId) { 29 | return bookService.validateAndGetBookById(bookId); 30 | } 31 | 32 | @QueryMapping 33 | public Book getBookByIsbn(@Argument String bookIsbn) { 34 | return bookService.validateAndGetBookByIsbn(bookIsbn); 35 | } 36 | 37 | @MutationMapping 38 | public Book createBook(@Argument BookInput bookInput) { 39 | Book book = Book.from(bookInput); 40 | return bookService.saveBook(book); 41 | } 42 | 43 | @MutationMapping 44 | public Book updateBook(@Argument String bookId, @Argument BookInput bookInput) { 45 | Book book = bookService.validateAndGetBookById(bookId); 46 | Book.updateFrom(bookInput, book); 47 | return bookService.saveBook(book); 48 | } 49 | 50 | @MutationMapping 51 | public Book deleteBook(@Argument String bookId) { 52 | Book book = bookService.validateAndGetBookById(bookId); 53 | bookService.deleteBook(book); 54 | return book; 55 | } 56 | 57 | @MutationMapping 58 | public Book addBookReview(@Argument String bookId, @Argument ReviewInput reviewInput) { 59 | Book book = bookService.validateAndGetBookById(bookId); 60 | Review review = Review.from(reviewInput); 61 | book.getReviews().addFirst(review); 62 | return bookService.saveBook(book); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/graphql/BookReviewExceptionResolver.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.graphql; 2 | 3 | import com.ivanfranchin.bookreviewapi.exception.BookDuplicatedIsbnException; 4 | import com.ivanfranchin.bookreviewapi.exception.BookNotFoundException; 5 | import graphql.GraphQLError; 6 | import graphql.GraphqlErrorBuilder; 7 | import graphql.schema.DataFetchingEnvironment; 8 | import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; 9 | import org.springframework.graphql.execution.ErrorType; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | public class BookReviewExceptionResolver extends DataFetcherExceptionResolverAdapter { 14 | 15 | @Override 16 | protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { 17 | ErrorType errorType = ErrorType.INTERNAL_ERROR; 18 | if (ex instanceof BookNotFoundException) { 19 | errorType = ErrorType.NOT_FOUND; 20 | } else if (ex instanceof BookDuplicatedIsbnException) { 21 | errorType = ErrorType.BAD_REQUEST; 22 | } 23 | 24 | return GraphqlErrorBuilder.newError(env) 25 | .message("Resolved error: " + ex.getMessage()) 26 | .errorType(errorType) 27 | .build(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/graphql/input/BookInput.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.graphql.input; 2 | 3 | public record BookInput(String isbn, String title) { 4 | } 5 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/graphql/input/ReviewInput.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.graphql.input; 2 | 3 | public record ReviewInput(String reviewer, String comment, Integer rating) { 4 | } 5 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/model/Book.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.model; 2 | 3 | import com.ivanfranchin.bookreviewapi.graphql.input.BookInput; 4 | import lombok.Data; 5 | import org.springframework.data.annotation.CreatedDate; 6 | import org.springframework.data.annotation.Id; 7 | import org.springframework.data.annotation.LastModifiedDate; 8 | import org.springframework.data.mongodb.core.index.Indexed; 9 | import org.springframework.data.mongodb.core.mapping.Document; 10 | 11 | import java.time.Instant; 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | 15 | @Data 16 | @Document(collection = "books") 17 | public class Book { 18 | 19 | @Id 20 | private String id; 21 | 22 | @Indexed(unique = true) 23 | private String isbn; 24 | 25 | private String title; 26 | 27 | private List reviews = new ArrayList<>(); 28 | 29 | @CreatedDate 30 | private Instant createdAt; 31 | 32 | @LastModifiedDate 33 | private Instant updatedAt; 34 | 35 | public static Book from(BookInput bookInput) { 36 | Book book = new Book(); 37 | book.setIsbn(bookInput.isbn()); 38 | book.setTitle(bookInput.title()); 39 | return book; 40 | } 41 | 42 | public static void updateFrom(BookInput bookInput, Book book) { 43 | if (bookInput.isbn() != null) { 44 | book.setIsbn(bookInput.isbn()); 45 | } 46 | 47 | if (bookInput.title() != null) { 48 | book.setTitle(bookInput.title()); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/model/Review.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.model; 2 | 3 | import com.ivanfranchin.bookreviewapi.graphql.input.ReviewInput; 4 | 5 | import java.time.Instant; 6 | 7 | public record Review(String reviewer, String comment, Integer rating, String createdAt) { 8 | 9 | public static Review from(ReviewInput reviewInput) { 10 | String reviewer = reviewInput.reviewer(); 11 | String comment = reviewInput.comment(); 12 | Integer rating = reviewInput.rating(); 13 | String createdAt = Instant.ofEpochSecond(Instant.now().getEpochSecond()).toString(); 14 | return new Review(reviewer, comment, rating, createdAt); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/repository/BookRepository.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.repository; 2 | 3 | import com.ivanfranchin.bookreviewapi.model.Book; 4 | import org.springframework.data.mongodb.repository.MongoRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.Optional; 8 | 9 | @Repository 10 | public interface BookRepository extends MongoRepository { 11 | 12 | Optional findByIsbn(String isbn); 13 | } 14 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/service/BookService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.service; 2 | 3 | import com.ivanfranchin.bookreviewapi.model.Book; 4 | 5 | import java.util.List; 6 | 7 | public interface BookService { 8 | 9 | List getBooks(); 10 | 11 | Book validateAndGetBookById(String id); 12 | 13 | Book saveBook(Book book); 14 | 15 | void deleteBook(Book book); 16 | 17 | Book validateAndGetBookByIsbn(String isbn); 18 | } 19 | -------------------------------------------------------------------------------- /book-review-api/src/main/java/com/ivanfranchin/bookreviewapi/service/BookServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi.service; 2 | 3 | import com.ivanfranchin.bookreviewapi.exception.BookDuplicatedIsbnException; 4 | import com.ivanfranchin.bookreviewapi.exception.BookNotFoundException; 5 | import com.ivanfranchin.bookreviewapi.model.Book; 6 | import com.ivanfranchin.bookreviewapi.repository.BookRepository; 7 | import lombok.RequiredArgsConstructor; 8 | import org.springframework.dao.DataIntegrityViolationException; 9 | import org.springframework.stereotype.Service; 10 | 11 | import java.util.List; 12 | 13 | @RequiredArgsConstructor 14 | @Service 15 | public class BookServiceImpl implements BookService { 16 | 17 | private final BookRepository bookRepository; 18 | 19 | @Override 20 | public List getBooks() { 21 | return bookRepository.findAll(); 22 | } 23 | 24 | @Override 25 | public Book validateAndGetBookById(String id) { 26 | return bookRepository.findById(id) 27 | .orElseThrow(() -> new BookNotFoundException(String.format("Book with id %s not found", id))); 28 | } 29 | 30 | @Override 31 | public Book saveBook(Book book) { 32 | try { 33 | return bookRepository.save(book); 34 | } catch (DataIntegrityViolationException e) { 35 | throw new BookDuplicatedIsbnException(String.format("Book with ISBN '%s' already exists", book.getIsbn())); 36 | } 37 | } 38 | 39 | @Override 40 | public void deleteBook(Book book) { 41 | bookRepository.delete(book); 42 | } 43 | 44 | @Override 45 | public Book validateAndGetBookByIsbn(String isbn) { 46 | return bookRepository.findByIsbn(isbn) 47 | .orElseThrow(() -> new BookNotFoundException(String.format("Book with isbn %s not found", isbn))); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /book-review-api/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server.port: 9080 2 | 3 | spring: 4 | application: 5 | name: book-review-api 6 | data: 7 | mongodb: 8 | host: ${MONGODB_HOST:localhost} 9 | port: ${MONGODB_PORT:27017} 10 | database: bookreviewdb 11 | username: change-me 12 | password: change-me 13 | auto-index-creation: true 14 | graphql: 15 | graphiql: 16 | enabled: true 17 | cors: 18 | allowed-origins: ${app.cors.allowed-origins} 19 | 20 | app: 21 | cors: 22 | allowed-origins: http://localhost:3000, http://localhost:3001 23 | 24 | management: 25 | tracing: 26 | sampling: 27 | probability: 1.0 28 | zipkin: 29 | tracing: 30 | endpoint: http://${ZIPKIN_HOST:localhost}:${ZIPKIN_PORT:9411}/api/v2/spans 31 | -------------------------------------------------------------------------------- /book-review-api/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ _ _ _ 2 | | |__ ___ ___ | | __ _ __ _____ _(_) _____ __ __ _ _ __ (_) 3 | | '_ \ / _ \ / _ \| |/ /____| '__/ _ \ \ / / |/ _ \ \ /\ / /____ / _` | '_ \| | 4 | | |_) | (_) | (_) | <_____| | | __/\ V /| | __/\ V V /_____| (_| | |_) | | 5 | |_.__/ \___/ \___/|_|\_\ |_| \___| \_/ |_|\___| \_/\_/ \__,_| .__/|_| 6 | |_| 7 | :: Spring Boot :: ${spring-boot.formatted-version} 8 | -------------------------------------------------------------------------------- /book-review-api/src/main/resources/graphql/book-review.graphqls: -------------------------------------------------------------------------------- 1 | # ---- 2 | # Book 3 | 4 | type Book { 5 | id: ID! 6 | isbn: String! 7 | title: String! 8 | reviews: [Review!] 9 | } 10 | 11 | input CreateBookInput { 12 | isbn: String! 13 | title: String! 14 | } 15 | 16 | input UpdateBookInput { 17 | isbn: String 18 | title: String 19 | } 20 | 21 | # ------ 22 | # Review 23 | 24 | type Review { 25 | reviewer: String! 26 | comment: String! 27 | rating: Int! 28 | createdAt: String! 29 | } 30 | 31 | input ReviewInput { 32 | reviewer: String! 33 | comment: String! 34 | rating: Int! 35 | } 36 | 37 | # --- 38 | 39 | type Query { 40 | getBooks: [Book] 41 | getBookById(bookId:ID!): Book 42 | 43 | getBookByIsbn(bookIsbn:String!): Book 44 | } 45 | 46 | # --- 47 | 48 | type Mutation { 49 | createBook(bookInput:CreateBookInput!): Book! 50 | updateBook(bookId:ID!, bookInput:UpdateBookInput!): Book! 51 | deleteBook(bookId:ID!): Book! 52 | 53 | addBookReview(bookId:ID!, reviewInput: ReviewInput): Book! 54 | } -------------------------------------------------------------------------------- /book-review-api/src/test/java/com/ivanfranchin/bookreviewapi/BookReviewApiApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.bookreviewapi; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | @Disabled 8 | @SpringBootTest 9 | class BookReviewApiApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /build-docker-images.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DOCKER_IMAGE_PREFIX="ivanfranchin" 4 | APP_VERSION="1.0.0" 5 | 6 | AUTHOR_BOOK_API_APP_NAME="author-book-api" 7 | BOOK_REVIEW_API_APP_NAME="book-review-api" 8 | 9 | AUTHOR_BOOK_API_DOCKER_IMAGE_NAME="${DOCKER_IMAGE_PREFIX}/${AUTHOR_BOOK_API_APP_NAME}:${APP_VERSION}" 10 | BOOK_REVIEW_API_DOCKER_IMAGE_NAME="${DOCKER_IMAGE_PREFIX}/${BOOK_REVIEW_API_APP_NAME}:${APP_VERSION}" 11 | 12 | SKIP_TESTS="true" 13 | 14 | ./mvnw clean spring-boot:build-image \ 15 | --projects "$AUTHOR_BOOK_API_APP_NAME" \ 16 | -DskipTests="$SKIP_TESTS" \ 17 | -Dspring-boot.build-image.imageName="$AUTHOR_BOOK_API_DOCKER_IMAGE_NAME" 18 | 19 | ./mvnw clean spring-boot:build-image \ 20 | --projects "$BOOK_REVIEW_API_APP_NAME" \ 21 | -DskipTests="$SKIP_TESTS" \ 22 | -Dspring-boot.build-image.imageName="$BOOK_REVIEW_API_DOCKER_IMAGE_NAME" 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | mysql: 4 | container_name: 'mysql' 5 | image: 'mysql:9.1.0' 6 | ports: 7 | - '3306:3306' 8 | environment: 9 | - 'MYSQL_DATABASE=authorbookdb' 10 | - 'MYSQL_USER=authorbookuser' 11 | - 'MYSQL_PASSWORD=authorbookpass' 12 | - 'MYSQL_ROOT_PASSWORD=secret' 13 | healthcheck: 14 | test: 'mysqladmin ping -u root -p$${MYSQL_ROOT_PASSWORD}' 15 | 16 | mongodb: 17 | image: 'bitnami/mongodb:8.0.3' 18 | container_name: 'mongodb' 19 | ports: 20 | - '27017:27017' 21 | environment: 22 | - 'MONGODB_DATABASE=bookreviewdb' 23 | - 'MONGODB_USERNAME=bookreviewuser' 24 | - 'MONGODB_PASSWORD=bookreviewpass' 25 | - 'MONGODB_ROOT_PASSWORD=secret' 26 | healthcheck: 27 | test: "echo 'db.stats().ok' | mongosh localhost:27017/bookreviewdb --quiet" 28 | 29 | zipkin: 30 | image: 'openzipkin/zipkin:3.4.1' 31 | container_name: 'zipkin' 32 | ports: 33 | - '9411:9411' 34 | healthcheck: 35 | test: [ "CMD", "nc", "-z", "localhost", "9411" ] 36 | -------------------------------------------------------------------------------- /documentation/project-diagram.excalidraw: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "rectangle", 8 | "version": 538, 9 | "versionNonce": 902772554, 10 | "isDeleted": false, 11 | "id": "HIFYGYXVMolqkvBk57YWy", 12 | "fillStyle": "hachure", 13 | "strokeWidth": 1, 14 | "strokeStyle": "solid", 15 | "roughness": 1, 16 | "opacity": 100, 17 | "angle": 0, 18 | "x": 93.93024518069933, 19 | "y": 230.04674749451232, 20 | "strokeColor": "#000000", 21 | "backgroundColor": "#ced4da", 22 | "width": 903, 23 | "height": 195, 24 | "seed": 2004346070, 25 | "groupIds": [], 26 | "roundness": { 27 | "type": 3 28 | }, 29 | "boundElements": [ 30 | { 31 | "type": "text", 32 | "id": "iynOiyLl-qSVIMpHijdSd" 33 | } 34 | ], 35 | "updated": 1678997157411, 36 | "link": null, 37 | "locked": false 38 | }, 39 | { 40 | "type": "text", 41 | "version": 230, 42 | "versionNonce": 995768918, 43 | "isDeleted": false, 44 | "id": "iynOiyLl-qSVIMpHijdSd", 45 | "fillStyle": "solid", 46 | "strokeWidth": 1, 47 | "strokeStyle": "solid", 48 | "roughness": 1, 49 | "opacity": 100, 50 | "angle": 0, 51 | "x": 98.93024518069933, 52 | "y": 235.04674749451232, 53 | "strokeColor": "#000000", 54 | "backgroundColor": "#ffffff", 55 | "width": 254.05978393554688, 56 | "height": 48, 57 | "seed": 1938336662, 58 | "groupIds": [], 59 | "roundness": null, 60 | "boundElements": [], 61 | "updated": 1678997157411, 62 | "link": null, 63 | "locked": false, 64 | "fontSize": 20, 65 | "fontFamily": 1, 66 | "text": " react-graphql-databases\n Github project", 67 | "textAlign": "left", 68 | "verticalAlign": "top", 69 | "containerId": "HIFYGYXVMolqkvBk57YWy", 70 | "originalText": " react-graphql-databases\n Github project" 71 | }, 72 | { 73 | "type": "rectangle", 74 | "version": 1127, 75 | "versionNonce": 1030590090, 76 | "isDeleted": false, 77 | "id": "NKmNZxYxWMCKh3prRiPwX", 78 | "fillStyle": "hachure", 79 | "strokeWidth": 1, 80 | "strokeStyle": "solid", 81 | "roughness": 1, 82 | "opacity": 100, 83 | "angle": 0, 84 | "x": 0.9190894568429258, 85 | "y": 42.65535192566466, 86 | "strokeColor": "#000000", 87 | "backgroundColor": "#82c91e", 88 | "width": 209.18356323242188, 89 | "height": 99.67071533203125, 90 | "seed": 1526615674, 91 | "groupIds": [], 92 | "roundness": { 93 | "type": 3 94 | }, 95 | "boundElements": [ 96 | { 97 | "type": "text", 98 | "id": "cCjj3is4loi-W2xxel_Uz" 99 | }, 100 | { 101 | "id": "BLvLKYVQLjBHc1ahI-AU0", 102 | "type": "arrow" 103 | } 104 | ], 105 | "updated": 1678996214452, 106 | "link": null, 107 | "locked": false 108 | }, 109 | { 110 | "type": "text", 111 | "version": 60, 112 | "versionNonce": 656843222, 113 | "isDeleted": false, 114 | "id": "cCjj3is4loi-W2xxel_Uz", 115 | "fillStyle": "hachure", 116 | "strokeWidth": 1, 117 | "strokeStyle": "solid", 118 | "roughness": 1, 119 | "opacity": 100, 120 | "angle": 0, 121 | "x": 61.452895304010895, 122 | "y": 75.69070959168027, 123 | "strokeColor": "#000000", 124 | "backgroundColor": "transparent", 125 | "width": 88.11595153808594, 126 | "height": 33.6, 127 | "seed": 1357632714, 128 | "groupIds": [], 129 | "roundness": null, 130 | "boundElements": [], 131 | "updated": 1678996214452, 132 | "link": null, 133 | "locked": false, 134 | "fontSize": 28, 135 | "fontFamily": 1, 136 | "text": "MySQL", 137 | "textAlign": "center", 138 | "verticalAlign": "middle", 139 | "containerId": "NKmNZxYxWMCKh3prRiPwX", 140 | "originalText": "MySQL" 141 | }, 142 | { 143 | "type": "rectangle", 144 | "version": 1201, 145 | "versionNonce": 314655690, 146 | "isDeleted": false, 147 | "id": "AmDajC5Y1CXP4EO3n8tQr", 148 | "fillStyle": "hachure", 149 | "strokeWidth": 1, 150 | "strokeStyle": "solid", 151 | "roughness": 1, 152 | "opacity": 100, 153 | "angle": 0, 154 | "x": 283.3393417193853, 155 | "y": 45.990709591680286, 156 | "strokeColor": "#000000", 157 | "backgroundColor": "#15aabf", 158 | "width": 209, 159 | "height": 93, 160 | "seed": 1497471780, 161 | "groupIds": [], 162 | "roundness": { 163 | "type": 3 164 | }, 165 | "boundElements": [ 166 | { 167 | "type": "text", 168 | "id": "P5pUv584_fMEhcy1PvLgG" 169 | }, 170 | { 171 | "id": "BLvLKYVQLjBHc1ahI-AU0", 172 | "type": "arrow" 173 | }, 174 | { 175 | "id": "0vcNN6zot8l01mlxmsEX4", 176 | "type": "arrow" 177 | } 178 | ], 179 | "updated": 1678996226756, 180 | "link": null, 181 | "locked": false 182 | }, 183 | { 184 | "type": "text", 185 | "version": 1250, 186 | "versionNonce": 1182054550, 187 | "isDeleted": false, 188 | "id": "P5pUv584_fMEhcy1PvLgG", 189 | "fillStyle": "hachure", 190 | "strokeWidth": 1, 191 | "strokeStyle": "solid", 192 | "roughness": 1, 193 | "opacity": 100, 194 | "angle": 0, 195 | "x": 333.3373733355962, 196 | "y": 58.890709591680285, 197 | "strokeColor": "#000000", 198 | "backgroundColor": "transparent", 199 | "width": 109.00393676757812, 200 | "height": 67.2, 201 | "seed": 160700700, 202 | "groupIds": [], 203 | "roundness": null, 204 | "boundElements": [], 205 | "updated": 1678996226756, 206 | "link": null, 207 | "locked": false, 208 | "fontSize": 28, 209 | "fontFamily": 1, 210 | "text": "author-\nbook-api", 211 | "textAlign": "center", 212 | "verticalAlign": "middle", 213 | "containerId": "AmDajC5Y1CXP4EO3n8tQr", 214 | "originalText": "author-\nbook-api" 215 | }, 216 | { 217 | "type": "rectangle", 218 | "version": 1351, 219 | "versionNonce": 1232185494, 220 | "isDeleted": false, 221 | "id": "Ms1oYj4lUmfpt4ZJp8o_U", 222 | "fillStyle": "hachure", 223 | "strokeWidth": 1, 224 | "strokeStyle": "solid", 225 | "roughness": 1, 226 | "opacity": 100, 227 | "angle": 0, 228 | "x": 619.1174941852056, 229 | "y": 44.990709591680286, 230 | "strokeColor": "#000000", 231 | "backgroundColor": "#fd7e14", 232 | "width": 210, 233 | "height": 95, 234 | "seed": 1798893852, 235 | "groupIds": [], 236 | "roundness": { 237 | "type": 3 238 | }, 239 | "boundElements": [ 240 | { 241 | "type": "text", 242 | "id": "LT5WIKwQy0s1vrSimj1Kp" 243 | }, 244 | { 245 | "id": "0vcNN6zot8l01mlxmsEX4", 246 | "type": "arrow" 247 | }, 248 | { 249 | "id": "FYu8cOjXcGy_xvBsnCk7l", 250 | "type": "arrow" 251 | }, 252 | { 253 | "id": "tjvFLt4U9ZvAaI55eu2bX", 254 | "type": "arrow" 255 | } 256 | ], 257 | "updated": 1678996358896, 258 | "link": null, 259 | "locked": false 260 | }, 261 | { 262 | "type": "text", 263 | "version": 1405, 264 | "versionNonce": 910047318, 265 | "isDeleted": false, 266 | "id": "LT5WIKwQy0s1vrSimj1Kp", 267 | "fillStyle": "hachure", 268 | "strokeWidth": 1, 269 | "strokeStyle": "solid", 270 | "roughness": 1, 271 | "opacity": 100, 272 | "angle": 0, 273 | "x": 658.8075347735845, 274 | "y": 58.890709591680285, 275 | "strokeColor": "#000000", 276 | "backgroundColor": "transparent", 277 | "width": 130.6199188232422, 278 | "height": 67.2, 279 | "seed": 903823012, 280 | "groupIds": [], 281 | "roundness": null, 282 | "boundElements": [], 283 | "updated": 1678996232319, 284 | "link": null, 285 | "locked": false, 286 | "fontSize": 28, 287 | "fontFamily": 1, 288 | "text": "book-\nreview-api", 289 | "textAlign": "center", 290 | "verticalAlign": "middle", 291 | "containerId": "Ms1oYj4lUmfpt4ZJp8o_U", 292 | "originalText": "book-\nreview-api" 293 | }, 294 | { 295 | "type": "rectangle", 296 | "version": 1226, 297 | "versionNonce": 1046995658, 298 | "isDeleted": false, 299 | "id": "Rp9rO-Tbt-71IQ5_S_BbJ", 300 | "fillStyle": "hachure", 301 | "strokeWidth": 1, 302 | "strokeStyle": "solid", 303 | "roughness": 1, 304 | "opacity": 100, 305 | "angle": 0, 306 | "x": 894.8591850000353, 307 | "y": 42.65535192566466, 308 | "strokeColor": "#000000", 309 | "backgroundColor": "#82c91e", 310 | "width": 209.18356323242188, 311 | "height": 99.67071533203125, 312 | "seed": 969981014, 313 | "groupIds": [], 314 | "roundness": { 315 | "type": 3 316 | }, 317 | "boundElements": [ 318 | { 319 | "type": "text", 320 | "id": "hzwxNV8uCDkuIZFOtGDW0" 321 | } 322 | ], 323 | "updated": 1678996214452, 324 | "link": null, 325 | "locked": false 326 | }, 327 | { 328 | "type": "text", 329 | "version": 165, 330 | "versionNonce": 68266390, 331 | "isDeleted": false, 332 | "id": "hzwxNV8uCDkuIZFOtGDW0", 333 | "fillStyle": "hachure", 334 | "strokeWidth": 1, 335 | "strokeStyle": "solid", 336 | "roughness": 1, 337 | "opacity": 100, 338 | "angle": 0, 339 | "x": 959.6629798608751, 340 | "y": 75.69070959168029, 341 | "strokeColor": "#000000", 342 | "backgroundColor": "transparent", 343 | "width": 79.57597351074219, 344 | "height": 33.6, 345 | "seed": 367549450, 346 | "groupIds": [], 347 | "roundness": null, 348 | "boundElements": [], 349 | "updated": 1678996214452, 350 | "link": null, 351 | "locked": false, 352 | "fontSize": 28, 353 | "fontFamily": 1, 354 | "text": "Mongo", 355 | "textAlign": "center", 356 | "verticalAlign": "middle", 357 | "containerId": "Rp9rO-Tbt-71IQ5_S_BbJ", 358 | "originalText": "Mongo" 359 | }, 360 | { 361 | "type": "rectangle", 362 | "version": 1398, 363 | "versionNonce": 1782431242, 364 | "isDeleted": false, 365 | "id": "ktEFVKXrYunJ6HgZNEWoC", 366 | "fillStyle": "hachure", 367 | "strokeWidth": 1, 368 | "strokeStyle": "solid", 369 | "roughness": 1, 370 | "opacity": 100, 371 | "angle": 0, 372 | "x": 280.56962736391654, 373 | "y": 308.5624640472467, 374 | "strokeColor": "#000000", 375 | "backgroundColor": "#228be6", 376 | "width": 210, 377 | "height": 95, 378 | "seed": 583947542, 379 | "groupIds": [], 380 | "roundness": { 381 | "type": 3 382 | }, 383 | "boundElements": [ 384 | { 385 | "type": "text", 386 | "id": "zSvxSJPc-BwYD_FrCe2zB" 387 | }, 388 | { 389 | "id": "2sYv-_zIU1XRDKlbVDuiu", 390 | "type": "arrow" 391 | }, 392 | { 393 | "id": "FYu8cOjXcGy_xvBsnCk7l", 394 | "type": "arrow" 395 | } 396 | ], 397 | "updated": 1678997157411, 398 | "link": null, 399 | "locked": false 400 | }, 401 | { 402 | "type": "text", 403 | "version": 1450, 404 | "versionNonce": 806125462, 405 | "isDeleted": false, 406 | "id": "zSvxSJPc-BwYD_FrCe2zB", 407 | "fillStyle": "hachure", 408 | "strokeWidth": 1, 409 | "strokeStyle": "solid", 410 | "roughness": 1, 411 | "opacity": 100, 412 | "angle": 0, 413 | "x": 333.92365415835013, 414 | "y": 322.46246404724667, 415 | "strokeColor": "#000000", 416 | "backgroundColor": "transparent", 417 | "width": 103.29194641113281, 418 | "height": 67.2, 419 | "seed": 2008919882, 420 | "groupIds": [], 421 | "roundness": null, 422 | "boundElements": [], 423 | "updated": 1678997157411, 424 | "link": null, 425 | "locked": false, 426 | "fontSize": 28, 427 | "fontFamily": 1, 428 | "text": "author-\nbook-ui", 429 | "textAlign": "center", 430 | "verticalAlign": "middle", 431 | "containerId": "ktEFVKXrYunJ6HgZNEWoC", 432 | "originalText": "author-\nbook-ui" 433 | }, 434 | { 435 | "type": "rectangle", 436 | "version": 1469, 437 | "versionNonce": 1453081162, 438 | "isDeleted": false, 439 | "id": "q0I1NOSA8BxQISgsB8gkN", 440 | "fillStyle": "hachure", 441 | "strokeWidth": 1, 442 | "strokeStyle": "solid", 443 | "roughness": 1, 444 | "opacity": 100, 445 | "angle": 0, 446 | "x": 617.2559982135259, 447 | "y": 306.22710638123107, 448 | "strokeColor": "#000000", 449 | "backgroundColor": "#fab005", 450 | "width": 209.18356323242188, 451 | "height": 99.67071533203125, 452 | "seed": 164101718, 453 | "groupIds": [], 454 | "roundness": { 455 | "type": 3 456 | }, 457 | "boundElements": [ 458 | { 459 | "type": "text", 460 | "id": "OdnhtuyZGsmUibHbE9laJ" 461 | }, 462 | { 463 | "id": "TU-wHdi2jjivhFlEtVOTm", 464 | "type": "arrow" 465 | } 466 | ], 467 | "updated": 1678997157412, 468 | "link": null, 469 | "locked": false 470 | }, 471 | { 472 | "type": "text", 473 | "version": 1532, 474 | "versionNonce": 569859926, 475 | "isDeleted": false, 476 | "id": "OdnhtuyZGsmUibHbE9laJ", 477 | "fillStyle": "hachure", 478 | "strokeWidth": 1, 479 | "strokeStyle": "solid", 480 | "roughness": 1, 481 | "opacity": 100, 482 | "angle": 0, 483 | "x": 664.8258148028814, 484 | "y": 322.46246404724667, 485 | "strokeColor": "#000000", 486 | "backgroundColor": "transparent", 487 | "width": 114.04393005371094, 488 | "height": 67.2, 489 | "seed": 1434335754, 490 | "groupIds": [], 491 | "roundness": null, 492 | "boundElements": [], 493 | "updated": 1678997157412, 494 | "link": null, 495 | "locked": false, 496 | "fontSize": 28, 497 | "fontFamily": 1, 498 | "text": "book-\nreview-ui", 499 | "textAlign": "center", 500 | "verticalAlign": "middle", 501 | "containerId": "q0I1NOSA8BxQISgsB8gkN", 502 | "originalText": "book-\nreview-ui" 503 | }, 504 | { 505 | "type": "rectangle", 506 | "version": 242, 507 | "versionNonce": 1307264982, 508 | "isDeleted": false, 509 | "id": "meFLf4cQlYsotQ5utEuP4", 510 | "fillStyle": "solid", 511 | "strokeWidth": 1, 512 | "strokeStyle": "solid", 513 | "roughness": 1, 514 | "opacity": 100, 515 | "angle": 0, 516 | "x": 290.69333722171496, 517 | "y": 131.34435491638732, 518 | "strokeColor": "#000000", 519 | "backgroundColor": "#ffffff", 520 | "width": 93, 521 | "height": 44, 522 | "seed": 8747914, 523 | "groupIds": [], 524 | "roundness": { 525 | "type": 3 526 | }, 527 | "boundElements": [ 528 | { 529 | "type": "text", 530 | "id": "td6U9uT7URbkmQKiV8SGx" 531 | } 532 | ], 533 | "updated": 1678996346762, 534 | "link": null, 535 | "locked": false 536 | }, 537 | { 538 | "type": "text", 539 | "version": 131, 540 | "versionNonce": 1328433290, 541 | "isDeleted": false, 542 | "id": "td6U9uT7URbkmQKiV8SGx", 543 | "fillStyle": "hachure", 544 | "strokeWidth": 1, 545 | "strokeStyle": "solid", 546 | "roughness": 1, 547 | "opacity": 100, 548 | "angle": 0, 549 | "x": 309.5533530908556, 550 | "y": 141.34435491638732, 551 | "strokeColor": "#000000", 552 | "backgroundColor": "transparent", 553 | "width": 55.27996826171875, 554 | "height": 24, 555 | "seed": 2102503958, 556 | "groupIds": [], 557 | "roundness": null, 558 | "boundElements": [], 559 | "updated": 1678996346762, 560 | "link": null, 561 | "locked": false, 562 | "fontSize": 20, 563 | "fontFamily": 1, 564 | "text": "REST", 565 | "textAlign": "center", 566 | "verticalAlign": "middle", 567 | "containerId": "meFLf4cQlYsotQ5utEuP4", 568 | "originalText": "REST" 569 | }, 570 | { 571 | "type": "rectangle", 572 | "version": 380, 573 | "versionNonce": 941612310, 574 | "isDeleted": false, 575 | "id": "9tCsF5i_xETXYsHP9StCk", 576 | "fillStyle": "solid", 577 | "strokeWidth": 1, 578 | "strokeStyle": "solid", 579 | "roughness": 1, 580 | "opacity": 100, 581 | "angle": 0, 582 | "x": 391.42173077640246, 583 | "y": 133.07583074646544, 584 | "strokeColor": "#000000", 585 | "backgroundColor": "#ffffff", 586 | "width": 94, 587 | "height": 44, 588 | "seed": 807961162, 589 | "groupIds": [], 590 | "roundness": { 591 | "type": 3 592 | }, 593 | "boundElements": [ 594 | { 595 | "type": "text", 596 | "id": "ZMWVrbxhFWAfwGq4XRedi" 597 | }, 598 | { 599 | "id": "2sYv-_zIU1XRDKlbVDuiu", 600 | "type": "arrow" 601 | } 602 | ], 603 | "updated": 1678996346762, 604 | "link": null, 605 | "locked": false 606 | }, 607 | { 608 | "type": "text", 609 | "version": 272, 610 | "versionNonce": 1206464010, 611 | "isDeleted": false, 612 | "id": "ZMWVrbxhFWAfwGq4XRedi", 613 | "fillStyle": "hachure", 614 | "strokeWidth": 1, 615 | "strokeStyle": "solid", 616 | "roughness": 1, 617 | "opacity": 100, 618 | "angle": 0, 619 | "x": 396.65175702151964, 620 | "y": 143.07583074646544, 621 | "strokeColor": "#000000", 622 | "backgroundColor": "transparent", 623 | "width": 83.53994750976562, 624 | "height": 24, 625 | "seed": 642719574, 626 | "groupIds": [], 627 | "roundness": null, 628 | "boundElements": [], 629 | "updated": 1678996346763, 630 | "link": null, 631 | "locked": false, 632 | "fontSize": 20, 633 | "fontFamily": 1, 634 | "text": "GraphQL", 635 | "textAlign": "center", 636 | "verticalAlign": "middle", 637 | "containerId": "9tCsF5i_xETXYsHP9StCk", 638 | "originalText": "GraphQL" 639 | }, 640 | { 641 | "type": "rectangle", 642 | "version": 444, 643 | "versionNonce": 62559638, 644 | "isDeleted": false, 645 | "id": "qRIjExBfikembdGenXXby", 646 | "fillStyle": "solid", 647 | "strokeWidth": 1, 648 | "strokeStyle": "solid", 649 | "roughness": 1, 650 | "opacity": 100, 651 | "angle": 0, 652 | "x": 684.8294456201525, 653 | "y": 133.58767156677794, 654 | "strokeColor": "#000000", 655 | "backgroundColor": "#ffffff", 656 | "width": 94, 657 | "height": 44, 658 | "seed": 2102702922, 659 | "groupIds": [], 660 | "roundness": { 661 | "type": 3 662 | }, 663 | "boundElements": [ 664 | { 665 | "type": "text", 666 | "id": "Qm9lJCrqXLqBdf5zqBdlo" 667 | }, 668 | { 669 | "id": "TU-wHdi2jjivhFlEtVOTm", 670 | "type": "arrow" 671 | }, 672 | { 673 | "id": "FYu8cOjXcGy_xvBsnCk7l", 674 | "type": "arrow" 675 | } 676 | ], 677 | "updated": 1678996194137, 678 | "link": null, 679 | "locked": false 680 | }, 681 | { 682 | "type": "text", 683 | "version": 334, 684 | "versionNonce": 1842545686, 685 | "isDeleted": false, 686 | "id": "Qm9lJCrqXLqBdf5zqBdlo", 687 | "fillStyle": "hachure", 688 | "strokeWidth": 1, 689 | "strokeStyle": "solid", 690 | "roughness": 1, 691 | "opacity": 100, 692 | "angle": 0, 693 | "x": 690.0594718652696, 694 | "y": 143.58767156677794, 695 | "strokeColor": "#000000", 696 | "backgroundColor": "transparent", 697 | "width": 83.53994750976562, 698 | "height": 24, 699 | "seed": 905608790, 700 | "groupIds": [], 701 | "roundness": null, 702 | "boundElements": [], 703 | "updated": 1678996129194, 704 | "link": null, 705 | "locked": false, 706 | "fontSize": 20, 707 | "fontFamily": 1, 708 | "text": "GraphQL", 709 | "textAlign": "center", 710 | "verticalAlign": "middle", 711 | "containerId": "qRIjExBfikembdGenXXby", 712 | "originalText": "GraphQL" 713 | }, 714 | { 715 | "type": "arrow", 716 | "version": 163, 717 | "versionNonce": 1289563798, 718 | "isDeleted": false, 719 | "id": "BLvLKYVQLjBHc1ahI-AU0", 720 | "fillStyle": "solid", 721 | "strokeWidth": 1, 722 | "strokeStyle": "solid", 723 | "roughness": 1, 724 | "opacity": 100, 725 | "angle": 0, 726 | "x": 216.02195050296498, 727 | "y": 99.71283141808178, 728 | "strokeColor": "#000000", 729 | "backgroundColor": "#ffffff", 730 | "width": 54.34838867187497, 731 | "height": 0.8486862422441845, 732 | "seed": 1301453770, 733 | "groupIds": [], 734 | "roundness": { 735 | "type": 2 736 | }, 737 | "boundElements": [], 738 | "updated": 1678996218907, 739 | "link": null, 740 | "locked": false, 741 | "startBinding": { 742 | "elementId": "NKmNZxYxWMCKh3prRiPwX", 743 | "focus": 0.09499749732334616, 744 | "gap": 5.919297813700155 745 | }, 746 | "endBinding": { 747 | "elementId": "AmDajC5Y1CXP4EO3n8tQr", 748 | "focus": -0.09756366923904079, 749 | "gap": 2.9252640002234784 750 | }, 751 | "lastCommittedPoint": null, 752 | "startArrowhead": null, 753 | "endArrowhead": null, 754 | "points": [ 755 | [ 756 | 0, 757 | 0 758 | ], 759 | [ 760 | 54.34838867187497, 761 | -0.8486862422441845 762 | ] 763 | ] 764 | }, 765 | { 766 | "type": "arrow", 767 | "version": 123, 768 | "versionNonce": 1570514390, 769 | "isDeleted": false, 770 | "id": "tjvFLt4U9ZvAaI55eu2bX", 771 | "fillStyle": "solid", 772 | "strokeWidth": 1, 773 | "strokeStyle": "solid", 774 | "roughness": 1, 775 | "opacity": 100, 776 | "angle": 0, 777 | "x": 836.5889060693713, 778 | "y": 95.2272408064975, 779 | "strokeColor": "#000000", 780 | "backgroundColor": "#ffffff", 781 | "width": 59.335388183593636, 782 | "height": 1.0939149428445631, 783 | "seed": 282955082, 784 | "groupIds": [], 785 | "roundness": { 786 | "type": 2 787 | }, 788 | "boundElements": [], 789 | "updated": 1678996359299, 790 | "link": null, 791 | "locked": false, 792 | "startBinding": { 793 | "elementId": "Ms1oYj4lUmfpt4ZJp8o_U", 794 | "focus": 0.0972993139651712, 795 | "gap": 7.471411884165718 796 | }, 797 | "endBinding": null, 798 | "lastCommittedPoint": null, 799 | "startArrowhead": null, 800 | "endArrowhead": null, 801 | "points": [ 802 | [ 803 | 0, 804 | 0 805 | ], 806 | [ 807 | 59.335388183593636, 808 | -1.0939149428445631 809 | ] 810 | ] 811 | }, 812 | { 813 | "type": "arrow", 814 | "version": 124, 815 | "versionNonce": 900131798, 816 | "isDeleted": false, 817 | "id": "0vcNN6zot8l01mlxmsEX4", 818 | "fillStyle": "solid", 819 | "strokeWidth": 1, 820 | "strokeStyle": "solid", 821 | "roughness": 1, 822 | "opacity": 100, 823 | "angle": 0, 824 | "x": 487.5443504053087, 825 | "y": 97.05585668595677, 826 | "strokeColor": "#000000", 827 | "backgroundColor": "#ffffff", 828 | "width": 135.4813232421875, 829 | "height": 2.5398813078873417, 830 | "seed": 2093990666, 831 | "groupIds": [], 832 | "roundness": { 833 | "type": 2 834 | }, 835 | "boundElements": [], 836 | "updated": 1678996218908, 837 | "link": null, 838 | "locked": false, 839 | "startBinding": { 840 | "elementId": "AmDajC5Y1CXP4EO3n8tQr", 841 | "focus": 0.11426350444048006, 842 | "gap": 5.248747230245272 843 | }, 844 | "endBinding": { 845 | "elementId": "Ms1oYj4lUmfpt4ZJp8o_U", 846 | "focus": -0.00025827335490862885, 847 | "gap": 2.3493973010047284 848 | }, 849 | "lastCommittedPoint": null, 850 | "startArrowhead": "arrow", 851 | "endArrowhead": "arrow", 852 | "points": [ 853 | [ 854 | 0, 855 | 0 856 | ], 857 | [ 858 | 135.4813232421875, 859 | -2.5398813078873417 860 | ] 861 | ] 862 | }, 863 | { 864 | "type": "arrow", 865 | "version": 464, 866 | "versionNonce": 2026118358, 867 | "isDeleted": false, 868 | "id": "2sYv-_zIU1XRDKlbVDuiu", 869 | "fillStyle": "solid", 870 | "strokeWidth": 1, 871 | "strokeStyle": "solid", 872 | "roughness": 1, 873 | "opacity": 100, 874 | "angle": 0, 875 | "x": 387.44212852307794, 876 | "y": 181.969188562782, 877 | "strokeColor": "#000000", 878 | "backgroundColor": "#ffffff", 879 | "width": 0.45776342248416313, 880 | "height": 124.90977939071462, 881 | "seed": 530331402, 882 | "groupIds": [], 883 | "roundness": { 884 | "type": 2 885 | }, 886 | "boundElements": [], 887 | "updated": 1678997157412, 888 | "link": null, 889 | "locked": false, 890 | "startBinding": { 891 | "elementId": "9tCsF5i_xETXYsHP9StCk", 892 | "focus": 1.0807362066722968, 893 | "gap": 6.30731201171875 894 | }, 895 | "endBinding": { 896 | "elementId": "ktEFVKXrYunJ6HgZNEWoC", 897 | "focus": 0.01173761109091989, 898 | "gap": 1.6834960937500227 899 | }, 900 | "lastCommittedPoint": null, 901 | "startArrowhead": "arrow", 902 | "endArrowhead": "arrow", 903 | "points": [ 904 | [ 905 | 0, 906 | 0 907 | ], 908 | [ 909 | -0.45776342248416313, 910 | 124.90977939071462 911 | ] 912 | ] 913 | }, 914 | { 915 | "type": "arrow", 916 | "version": 460, 917 | "versionNonce": 454431254, 918 | "isDeleted": false, 919 | "id": "FYu8cOjXcGy_xvBsnCk7l", 920 | "fillStyle": "solid", 921 | "strokeWidth": 1, 922 | "strokeStyle": "solid", 923 | "roughness": 1, 924 | "opacity": 100, 925 | "angle": 0, 926 | "x": 407.36660398883663, 927 | "y": 296.9418951995904, 928 | "strokeColor": "#000000", 929 | "backgroundColor": "#ffffff", 930 | "width": 267.2621580375658, 931 | "height": 138.3434603430799, 932 | "seed": 743038998, 933 | "groupIds": [], 934 | "roundness": { 935 | "type": 2 936 | }, 937 | "boundElements": [], 938 | "updated": 1678997157412, 939 | "link": null, 940 | "locked": false, 941 | "startBinding": { 942 | "elementId": "ktEFVKXrYunJ6HgZNEWoC", 943 | "focus": -0.4696582916508317, 944 | "gap": 11.620568847656273 945 | }, 946 | "endBinding": { 947 | "elementId": "qRIjExBfikembdGenXXby", 948 | "focus": 0.5741179949900687, 949 | "gap": 10.20068359375 950 | }, 951 | "lastCommittedPoint": null, 952 | "startArrowhead": "arrow", 953 | "endArrowhead": "arrow", 954 | "points": [ 955 | [ 956 | 0, 957 | 0 958 | ], 959 | [ 960 | 267.2621580375658, 961 | -138.3434603430799 962 | ] 963 | ] 964 | }, 965 | { 966 | "type": "arrow", 967 | "version": 554, 968 | "versionNonce": 1908212886, 969 | "isDeleted": false, 970 | "id": "TU-wHdi2jjivhFlEtVOTm", 971 | "fillStyle": "solid", 972 | "strokeWidth": 1, 973 | "strokeStyle": "solid", 974 | "roughness": 1, 975 | "opacity": 100, 976 | "angle": 0, 977 | "x": 722.1724402388071, 978 | "y": 295.97695379334044, 979 | "strokeColor": "#000000", 980 | "backgroundColor": "#ffffff", 981 | "width": 3.5310607119212136, 982 | "height": 109.57791137695312, 983 | "seed": 1745934474, 984 | "groupIds": [], 985 | "roundness": { 986 | "type": 2 987 | }, 988 | "boundElements": [], 989 | "updated": 1678997157412, 990 | "link": null, 991 | "locked": false, 992 | "startBinding": { 993 | "elementId": "q0I1NOSA8BxQISgsB8gkN", 994 | "focus": 0.021285761205668118, 995 | "gap": 10.250152587890625 996 | }, 997 | "endBinding": { 998 | "elementId": "qRIjExBfikembdGenXXby", 999 | "focus": 0.2972386342834599, 1000 | "gap": 8.811370849609375 1001 | }, 1002 | "lastCommittedPoint": null, 1003 | "startArrowhead": "arrow", 1004 | "endArrowhead": "arrow", 1005 | "points": [ 1006 | [ 1007 | 0, 1008 | 0 1009 | ], 1010 | [ 1011 | -3.5310607119212136, 1012 | -109.57791137695312 1013 | ] 1014 | ] 1015 | } 1016 | ], 1017 | "appState": { 1018 | "gridSize": null, 1019 | "viewBackgroundColor": "#ffffff" 1020 | }, 1021 | "files": {} 1022 | } -------------------------------------------------------------------------------- /documentation/project-diagram.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-graphql-databases/c117a014f700a17dd503f6825e096ed6f769f170/documentation/project-diagram.jpeg -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.2 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 109 | while IFS="=" read -r key value; do 110 | case "${key-}" in 111 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 113 | esac 114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 116 | 117 | case "${distributionUrl##*/}" in 118 | maven-mvnd-*bin.*) 119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 125 | *) 126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 127 | distributionPlatform=linux-amd64 128 | ;; 129 | esac 130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 131 | ;; 132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 134 | esac 135 | 136 | # apply MVNW_REPOURL and calculate MAVEN_HOME 137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 139 | distributionUrlName="${distributionUrl##*/}" 140 | distributionUrlNameMain="${distributionUrlName%.*}" 141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 144 | 145 | exec_maven() { 146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 148 | } 149 | 150 | if [ -d "$MAVEN_HOME" ]; then 151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 152 | exec_maven "$@" 153 | fi 154 | 155 | case "${distributionUrl-}" in 156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 158 | esac 159 | 160 | # prepare tmp dir 161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 163 | trap clean HUP INT TERM EXIT 164 | else 165 | die "cannot create temp dir" 166 | fi 167 | 168 | mkdir -p -- "${MAVEN_HOME%/*}" 169 | 170 | # Download and Install Apache Maven 171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 172 | verbose "Downloading from: $distributionUrl" 173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 174 | 175 | # select .zip or .tar.gz 176 | if ! command -v unzip >/dev/null; then 177 | distributionUrl="${distributionUrl%.zip}.tar.gz" 178 | distributionUrlName="${distributionUrl##*/}" 179 | fi 180 | 181 | # verbose opt 182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 184 | 185 | # normalize http auth 186 | case "${MVNW_PASSWORD:+has-password}" in 187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 189 | esac 190 | 191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 192 | verbose "Found wget ... using wget" 193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 195 | verbose "Found curl ... using curl" 196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 197 | elif set_java_home; then 198 | verbose "Falling back to use Java to download" 199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 201 | cat >"$javaSource" <<-END 202 | public class Downloader extends java.net.Authenticator 203 | { 204 | protected java.net.PasswordAuthentication getPasswordAuthentication() 205 | { 206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 207 | } 208 | public static void main( String[] args ) throws Exception 209 | { 210 | setDefault( new Downloader() ); 211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 212 | } 213 | } 214 | END 215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 216 | verbose " - Compiling Downloader.java ..." 217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 218 | verbose " - Running Downloader.java ..." 219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 220 | fi 221 | 222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 223 | if [ -n "${distributionSha256Sum-}" ]; then 224 | distributionSha256Result=false 225 | if [ "$MVN_CMD" = mvnd.sh ]; then 226 | echo "Checksum validation is not supported for maven-mvnd." >&2 227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 228 | exit 1 229 | elif command -v sha256sum >/dev/null; then 230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 231 | distributionSha256Result=true 232 | fi 233 | elif command -v shasum >/dev/null; then 234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 235 | distributionSha256Result=true 236 | fi 237 | else 238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 240 | exit 1 241 | fi 242 | if [ $distributionSha256Result = false ]; then 243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 245 | exit 1 246 | fi 247 | fi 248 | 249 | # unzip and move 250 | if command -v unzip >/dev/null; then 251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 252 | else 253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 254 | fi 255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 257 | 258 | clean || : 259 | exec_maven "$@" 260 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.4.1 9 | 10 | 11 | com.ivanfranchin 12 | springboot-graphql-databases 13 | 1.0.0 14 | pom 15 | springboot-graphql-databases 16 | Demo project for Spring Boot 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 21 32 | 33 | 34 | 35 | author-book-api 36 | book-review-api 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /remove-docker-images.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker rmi ivanfranchin/author-book-api:1.0.0 4 | docker rmi ivanfranchin/book-review-api:1.0.0 5 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /start-apps.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source scripts/my-functions.sh 4 | 5 | echo 6 | echo "Starting author-book-api ..." 7 | 8 | docker run -d --rm --name author-book-api -p 8080:8080 \ 9 | -e MYSQL_HOST=mysql -e ZIPKIN_HOST=zipkin -e BOOK_REVIEW_API_HOST=book-review-api -e SPRING_DATASOURCE_USERNAME=authorbookuser -e SPRING_DATASOURCE_PASSWORD=authorbookpass \ 10 | --network=springboot-graphql-databases_default \ 11 | --health-cmd='[ -z "$(echo "" > /dev/tcp/localhost/8080)" ] || exit 1' \ 12 | ivanfranchin/author-book-api:1.0.0 13 | 14 | wait_for_container_log "author-book-api" "Started" 15 | 16 | echo 17 | echo "Starting book-review-api ..." 18 | 19 | docker run -d --rm --name book-review-api -p 9080:9080 \ 20 | -e MONGODB_HOST=mongodb -e ZIPKIN_HOST=zipkin -e SPRING_DATA_MONGODB_USERNAME=bookreviewuser -e SPRING_DATA_MONGODB_PASSWORD=bookreviewpass \ 21 | --network=springboot-graphql-databases_default \ 22 | --health-cmd='[ -z "$(echo "" > /dev/tcp/localhost/9080)" ] || exit 1' \ 23 | ivanfranchin/book-review-api:1.0.0 24 | 25 | wait_for_container_log "book-review-api" "Started" 26 | 27 | printf "\n" 28 | printf "%15s | %8s | %37s |\n" "Application" "URL Type" "URL" 29 | printf "%15s + %8s + %37s |\n" "---------------" "--------" "-------------------------------------" 30 | printf "%15s | %8s | %37s |\n" "author-book-api" "Swagger" "http://localhost:8080/swagger-ui.html" 31 | printf "%15s | %8s | %37s |\n" "author-book-api" "GraphiQL" "http://localhost:8080/graphiql" 32 | printf "%15s | %8s | %37s |\n" "book-review-api" "GraphiQL" "http://localhost:9080/graphiql" 33 | printf "\n" 34 | -------------------------------------------------------------------------------- /stop-apps.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker stop author-book-api book-review-api 4 | --------------------------------------------------------------------------------