diff --git a/README.md b/README.md index 45975db..8d766a6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Start the application and try it: ``` - OpenAPI/Swagger: http://localhost:8080/swagger-ui/index.html +- GraphiQL: http://localhost:8080/graphiql - Grafana: http://localhost:3000 - Spring Boot dashboard: http://localhost:3000/dashboards - Prometheus & Tempo: http://localhost:3000/explore diff --git a/build.gradle b/build.gradle index 69309f5..a5330cf 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ dependencyManagement { dependencies { // jpa-search-helper - implementation 'app.tozzi:jpa-search-helper:3.2.1' + implementation 'app.tozzi:jpa-search-helper:3.2.2' // Spring Boot dependencies implementation 'org.springframework.boot:spring-boot-starter-actuator' @@ -47,6 +47,9 @@ dependencies { implementation 'io.micrometer:micrometer-tracing-bridge-otel' implementation 'io.opentelemetry:opentelemetry-exporter-otlp' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + runtimeOnly 'com.netflix.graphql.dgs:graphql-dgs-spring-boot-micrometer' + + // Dev developmentOnly 'org.springframework.boot:spring-boot-docker-compose' // DB @@ -55,6 +58,7 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'com.netflix.graphql.dgs:graphql-dgs-spring-graphql-starter-test' } tasks.named('test') { diff --git a/src/main/java/app/tozzi/manager/BookManager.java b/src/main/java/app/tozzi/manager/BookManager.java index 2255575..958fcec 100644 --- a/src/main/java/app/tozzi/manager/BookManager.java +++ b/src/main/java/app/tozzi/manager/BookManager.java @@ -7,6 +7,7 @@ import io.micrometer.observation.annotation.Observed; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; @@ -18,10 +19,12 @@ public class BookManager { @Autowired private BookRepository bookRepository; + @Transactional(readOnly = true) public List findBooks(Map filters) { return bookRepository.findAllWithPaginationAndSorting(filters, Book.class).map(BookUtils::toBook).toList(); } + @Transactional(readOnly = true) public List findBooks(JPASearchInput input) { return bookRepository.findAllWithPaginationAndSorting(input, Book.class).map(BookUtils::toBook).toList(); } diff --git a/src/main/java/app/tozzi/repository/entity/AuthorEntity.java b/src/main/java/app/tozzi/repository/entity/AuthorEntity.java index 815bfdc..c49ff11 100644 --- a/src/main/java/app/tozzi/repository/entity/AuthorEntity.java +++ b/src/main/java/app/tozzi/repository/entity/AuthorEntity.java @@ -5,6 +5,7 @@ import lombok.Setter; import java.util.HashSet; +import java.util.Objects; import java.util.Set; @Entity @@ -32,4 +33,17 @@ public class AuthorEntity { @ManyToMany(mappedBy = "authors") private Set books = new HashSet<>(); + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + var that = (AuthorEntity) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } } diff --git a/src/main/java/app/tozzi/repository/entity/BookEntity.java b/src/main/java/app/tozzi/repository/entity/BookEntity.java index 17ca48d..26e79a4 100644 --- a/src/main/java/app/tozzi/repository/entity/BookEntity.java +++ b/src/main/java/app/tozzi/repository/entity/BookEntity.java @@ -9,6 +9,7 @@ import java.math.BigDecimal; import java.util.Date; import java.util.HashSet; +import java.util.Objects; import java.util.Set; @Entity @@ -52,4 +53,17 @@ public class BookEntity { @Formula("(SELECT AVG(s.PRICE) FROM SALES s WHERE s.book_id = id)") private BigDecimal avgPrice; + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + var that = (BookEntity) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } } diff --git a/src/main/java/app/tozzi/repository/entity/GenresEntity.java b/src/main/java/app/tozzi/repository/entity/GenresEntity.java index 1bb6db5..e9d01d8 100644 --- a/src/main/java/app/tozzi/repository/entity/GenresEntity.java +++ b/src/main/java/app/tozzi/repository/entity/GenresEntity.java @@ -5,6 +5,7 @@ import lombok.Setter; import java.util.HashSet; +import java.util.Objects; import java.util.Set; @Entity @@ -23,4 +24,18 @@ public class GenresEntity { @ManyToMany(mappedBy = "genres") private Set authors = new HashSet<>(); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + var that = (GenresEntity) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } } diff --git a/src/main/java/app/tozzi/repository/entity/SalesEntity.java b/src/main/java/app/tozzi/repository/entity/SalesEntity.java index 354a140..1230fc4 100644 --- a/src/main/java/app/tozzi/repository/entity/SalesEntity.java +++ b/src/main/java/app/tozzi/repository/entity/SalesEntity.java @@ -5,6 +5,7 @@ import lombok.Setter; import java.math.BigDecimal; +import java.util.Objects; @Entity @Getter @@ -26,4 +27,18 @@ public class SalesEntity { @Column(name = "CHANNEL") private String channel; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + var that = (SalesEntity) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1168640..415191b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -94,3 +94,18 @@ logging: org: hibernate: DEBUG +dgs: + graphql: + virtualthreads: + enabled: true + +server: + compression: + enabled: true + min-response-size: 10KB + servlet: + encoding: + charset: UTF-8 + force-request: true + force-response: true + shutdown: graceful \ No newline at end of file diff --git a/src/main/resources/schema/schema.graphql b/src/main/resources/schema/schema.graphql index aa4b479..b3df3c0 100644 --- a/src/main/resources/schema/schema.graphql +++ b/src/main/resources/schema/schema.graphql @@ -1,5 +1,6 @@ type Query { + # Advanced search with projection projection(filters: [Filter]): [Book] } \ No newline at end of file diff --git a/src/test/java/app/tozzi/controller/BookControllerUnitTest.java b/src/test/java/app/tozzi/controller/BookControllerUnitTest.java index 4a27a4c..be6ae0a 100644 --- a/src/test/java/app/tozzi/controller/BookControllerUnitTest.java +++ b/src/test/java/app/tozzi/controller/BookControllerUnitTest.java @@ -98,7 +98,7 @@ public void mode1_success() throws Exception { mvc.perform(get(PATH + "?isbn_eq=1234567891234567") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().is(200)) - .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)); + .andExpect(content().contentType("application/json;charset=UTF-8")); verify(bookManager, times(1)).findBooks(anyMap()); } @@ -122,7 +122,7 @@ public void mode2_success() throws Exception { .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().is(200)) - .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)); + .andExpect(content().contentType("application/json;charset=UTF-8")); verify(bookManager, times(1)).findBooks(any(JPASearchInput.class)); } diff --git a/src/test/java/app/tozzi/controller/GraphQLTest.java b/src/test/java/app/tozzi/controller/GraphQLTest.java new file mode 100644 index 0000000..e6235fe --- /dev/null +++ b/src/test/java/app/tozzi/controller/GraphQLTest.java @@ -0,0 +1,65 @@ +package app.tozzi.controller; + +import app.tozzi.datafetcher.BookDataFetcher; +import app.tozzi.model.Book; +import app.tozzi.repository.BookRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class GraphQLTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private BookDataFetcher bookDataFetcher; + + @MockBean + private BookRepository bookRepository; + + @Test + public void test_ok() throws Exception { + var book = Book.builder() + .isbn("1234567891234567") + .build(); + + when(bookRepository.projection(anyMap(), any(), any())).thenReturn(List.of(Map.of("isbn", "1234567890123456"))); + when(bookDataFetcher.projection(any(), any())).thenReturn(List.of(book)); + + mockMvc.perform(MockMvcRequestBuilders.post("/graphql") + .contentType("application/json") + .content("{\"query\":\"query {\\n\\tprojection(\\n\\t\\tfilters: [{ key: \\\"isbn_in\\\", value: \\\"1234567890123456,2234567890123456\\\" }]\\n\\t) {\\n\\t\\tisbn\\n\\t}\\n}\\n\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.projection[0].isbn").exists()); + + verify(bookDataFetcher, times(1)).projection(any(), any()); + } + + @Test + public void test_ko() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post("/graphql") + .contentType("application/json") + .content("{\"query\":\"{\\n projection(filters : [\\n { key: \\\"isbn_in\\\", value: \\\"1234567890123456,2234567890123456\\\" }\\n ]) {\\n isbna\\n }\\n }\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errors").exists()) + .andExpect(jsonPath("$.data").doesNotExist()); + + verify(bookDataFetcher, times(0)).projection(any(), any()); + } + +} diff --git a/src/test/java/app/tozzi/util/BookUtilsUnitTest.java b/src/test/java/app/tozzi/util/BookUtilsUnitTest.java index 8759afb..ee65771 100644 --- a/src/test/java/app/tozzi/util/BookUtilsUnitTest.java +++ b/src/test/java/app/tozzi/util/BookUtilsUnitTest.java @@ -40,7 +40,7 @@ public void toBook() { g1.setDescription("Fantasy"); GenresEntity g2 = new GenresEntity(); - g2.setId(1L); + g2.setId(2L); g2.setDescription("Horror"); a1.getGenres().add(g1);