├── .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 | 
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 |
--------------------------------------------------------------------------------