From 2922c7b34a7365895e5a9bd419c2176e9ca22820 Mon Sep 17 00:00:00 2001 From: Andrea Cavalli Date: Sat, 27 Nov 2021 00:54:51 +0100 Subject: [PATCH] Add ElasticSearch to chats service --- .editorconfig | 18 +++ service-chats/pom.xml | 35 ++++- .../src/main/java/io/volvox/chats/Chat.java | 16 +- ...sService.java => ChatEventBusService.java} | 12 +- .../java/io/volvox/chats/ChatRepository.java | 22 --- .../java/io/volvox/chats/ChatResource.java | 42 ++---- .../java/io/volvox/chats/ChatService.java | 140 ++++++++++++++++++ .../src/main/resources/application.properties | 5 + service-chats/src/main/resources/import.sql | 8 +- .../io/volvox/chats/ChatsEndpointTest.java | 25 +++- .../io/volvox/chats/ChatsEndpointTestIT.java | 9 ++ .../ElasticsearchContainerTestResource.java | 28 ++++ 12 files changed, 285 insertions(+), 75 deletions(-) create mode 100644 .editorconfig rename service-chats/src/main/java/io/volvox/chats/{ChatsService.java => ChatEventBusService.java} (65%) delete mode 100644 service-chats/src/main/java/io/volvox/chats/ChatRepository.java create mode 100644 service-chats/src/main/java/io/volvox/chats/ChatService.java create mode 100644 service-chats/src/test/java/io/volvox/chats/ChatsEndpointTestIT.java create mode 100644 service-chats/src/test/java/io/volvox/chats/ElasticsearchContainerTestResource.java diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a30f880 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = tab +insert_final_newline = true +max_line_length = 120 +tab_width = 2 + +[{*.java,*.jsh}] +indent_size = 4 +tab_width = 4 + +[{*.markdown,*.md}] +indent_size = 4 +tab_width = 4 diff --git a/service-chats/pom.xml b/service-chats/pom.xml index 16e10cf..45ffb4f 100644 --- a/service-chats/pom.xml +++ b/service-chats/pom.xml @@ -16,6 +16,7 @@ io.quarkus.platform 2.4.1.Final 3.0.0-M5 + 0.2.0 @@ -48,11 +49,12 @@ io.quarkus - quarkus-reactive-pg-client + quarkus-smallrye-openapi + io.quarkus - quarkus-smallrye-openapi + quarkus-resteasy-reactive io.quarkus @@ -62,17 +64,23 @@ io.quarkus quarkus-hibernate-reactive-panache + + + io.quarkus + quarkus-reactive-pg-client + io.quarkus quarkus-hibernate-validator - io.quarkus - quarkus-arc + org.elasticsearch.client + elasticsearch-rest-high-level-client - io.quarkus - quarkus-resteasy-reactive + io.quarkiverse.quarkus-elasticsearch-reactive + quarkus-elasticsearch-reactive + ${elasticsearch-reactive.version} @@ -90,6 +98,21 @@ assertj-core test + + org.testcontainers + testcontainers + test + + + org.testcontainers + elasticsearch + test + + + org.testcontainers + junit-jupiter + test + diff --git a/service-chats/src/main/java/io/volvox/chats/Chat.java b/service-chats/src/main/java/io/volvox/chats/Chat.java index d07174f..1df5ef6 100644 --- a/service-chats/src/main/java/io/volvox/chats/Chat.java +++ b/service-chats/src/main/java/io/volvox/chats/Chat.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import io.quarkus.hibernate.reactive.panache.PanacheEntityBase; +import io.smallrye.mutiny.Uni; import java.util.StringJoiner; import javax.persistence.Cacheable; import javax.persistence.Column; @@ -13,7 +14,7 @@ import javax.persistence.Id; import javax.validation.constraints.Max; import javax.validation.constraints.Pattern; import javax.validation.constraints.Positive; -import org.hibernate.validator.constraints.Length; +import javax.validation.constraints.Size; @Entity @Cacheable @@ -28,9 +29,9 @@ public class Chat extends PanacheEntityBase { public Long id; @Column(length = 128) public String name; - @Length(message = "Username length is not valid", min = 5) + @Size(message = "Username length is not valid", min = 5) @Column(length = 48) - @Length(message = "Username must not be an empty string", min = 1, max = 12 + 32) + @Size(message = "Username must not be an empty string", min = 1, max = 12 + 32) @Pattern(message = "Username contains invalid characters", regexp = "^(?:[a-zA-Z\\d][_]?)+$") @Pattern(message = "Username is not valid", regexp = "^(?:translation_|mv_)?[a-zA-Z]([a-zA-Z_\\d]){1,30}[a-zA-Z\\d]$") public String username; @@ -55,4 +56,13 @@ public class Chat extends PanacheEntityBase { .add("status=" + status) .toString(); } + + public static Uni findUsername(String username) { + if (username == null) { + throw new NullPointerException("Username must not be null"); + } else if (username.isBlank()) { + throw new NullPointerException("Username must not be blank"); + } + return find("from Chat where username = ?1", username).firstResult(); + } } diff --git a/service-chats/src/main/java/io/volvox/chats/ChatsService.java b/service-chats/src/main/java/io/volvox/chats/ChatEventBusService.java similarity index 65% rename from service-chats/src/main/java/io/volvox/chats/ChatsService.java rename to service-chats/src/main/java/io/volvox/chats/ChatEventBusService.java index 330a32b..6f0bcaf 100644 --- a/service-chats/src/main/java/io/volvox/chats/ChatsService.java +++ b/service-chats/src/main/java/io/volvox/chats/ChatEventBusService.java @@ -7,26 +7,26 @@ import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; @ApplicationScoped -public class ChatsService { +public class ChatEventBusService { @Inject EventBus bus; @Inject - ChatResource chatResource; + ChatService chatService; @ConsumeEvent(value = "chats.list") public void listChats(Message msg) { - chatResource.listSessions().collect().asList().subscribe().with(msg::reply); + chatService.listAll().collect().asList().subscribe().with(msg::reply); } @ConsumeEvent(value = "chats.get") public void get(Message msg) { - chatResource.get(msg.body()).subscribe().with(msg::reply); + chatService.get(msg.body()).subscribe().with(msg::reply); } @ConsumeEvent(value = "chats.update") public void update(Message msg) { - chatResource.update(msg.body().id, msg.body()).subscribe().with(msg::reply); + chatService.update(msg.body().id, msg.body()).subscribe().with(msg::reply); } -} \ No newline at end of file +} diff --git a/service-chats/src/main/java/io/volvox/chats/ChatRepository.java b/service-chats/src/main/java/io/volvox/chats/ChatRepository.java deleted file mode 100644 index 14dd399..0000000 --- a/service-chats/src/main/java/io/volvox/chats/ChatRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.volvox.chats; - -import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; -import io.smallrye.mutiny.Uni; -import javax.enterprise.context.ApplicationScoped; -import org.hibernate.annotations.NamedQueries; -import org.hibernate.annotations.NamedQuery; - -@ApplicationScoped -@NamedQueries({ - @NamedQuery(name = "Chat.getByName", query = "from Chat where name = ?1"), - @NamedQuery(name = "Chat.getByUsername", query = "from Chat where username = ?1"), - @NamedQuery(name = "Chat.countByStatus", query = "select count(*) from Chat p where p.status = :status"), - @NamedQuery(name = "Chat.updateStatusById", query = "update Chat p set p.status = :status where p.id = :id"), - @NamedQuery(name = "Chat.deleteById", query = "delete from Chat p where p.id = ?1") -}) -public class ChatRepository implements PanacheRepositoryBase { - - public Uni findByUsername(String username) { - return find("#Chat.getByUsername", username).firstResult(); - } -} diff --git a/service-chats/src/main/java/io/volvox/chats/ChatResource.java b/service-chats/src/main/java/io/volvox/chats/ChatResource.java index 3ef9e15..da34e17 100644 --- a/service-chats/src/main/java/io/volvox/chats/ChatResource.java +++ b/service-chats/src/main/java/io/volvox/chats/ChatResource.java @@ -1,14 +1,13 @@ package io.volvox.chats; -import io.quarkus.hibernate.reactive.panache.Panache; import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.Uni; import java.net.URI; +import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; import javax.ws.rs.GET; -import javax.ws.rs.NotFoundException; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; @@ -18,65 +17,52 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @Path("/chats") +@ApplicationScoped @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public class ChatResource { - @Inject - ChatRepository chatRepository; + @Inject + ChatService chatService; @GET - public Multi listSessions() { - return chatRepository.streamAll(); + public Multi list() { + return chatService.listAll(); } @GET @Path("/{id}") public Uni get(@PathParam("id") Long id) { - return chatRepository.findById(id); + return chatService.get(id); } @POST public Uni create(Chat chat) { - return Panache.withTransaction(() -> chatRepository.persist(chat)) + return chatService.create(chat) .onItem().transform(inserted -> Response.created(URI.create("/chats/" + chat.id)).build()); } @PUT @Path("/{id}") public Uni update(@PathParam("id") Long id, Chat chat) { - // Find chat by id - return Panache.withTransaction(() -> chatRepository.findById(id) - .flatMap(entity -> { - if (entity == null) { - // Persist the chat if not found - return chatRepository.persist(chat); - } else { - // Update all fields - entity.name = chat.name; - // Return the updated item - return Uni.createFrom().item(entity); - } - })); + return chatService.update(id, chat); } @DELETE @Path("/{id}") public Uni delete(@PathParam("id") Long id) { - return Panache.withTransaction(() -> chatRepository.findById(id) - .onItem().ifNull().failWith(NotFoundException::new) - .flatMap(chatRepository::delete)); + return chatService.delete(id); } @GET - @Path("/search/{username}") - public Uni search(@PathParam("username") String username) { - return chatRepository.findByUsername(username); + @Path("/by-username/{username}") + public Uni resolveByUsername(@PathParam("username") String username) { + return chatService.resolveByUsername(username); } @GET @Path("/count") public Uni count() { - return chatRepository.count(); + return chatService.count(); } } diff --git a/service-chats/src/main/java/io/volvox/chats/ChatService.java b/service-chats/src/main/java/io/volvox/chats/ChatService.java new file mode 100644 index 0000000..5956b97 --- /dev/null +++ b/service-chats/src/main/java/io/volvox/chats/ChatService.java @@ -0,0 +1,140 @@ +package io.volvox.chats; + +import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.hibernate.reactive.panache.PanacheEntityBase; +import io.reactiverse.elasticsearch.client.mutiny.RestHighLevelClient; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; +import io.vertx.core.json.JsonObject; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.ws.rs.NotFoundException; +import org.elasticsearch.action.delete.DeleteRequest; +import org.elasticsearch.action.delete.DeleteResponse; +import org.elasticsearch.action.get.GetRequest; +import org.elasticsearch.action.index.IndexRequest; +import org.elasticsearch.action.index.IndexResponse; +import org.elasticsearch.action.search.SearchRequest; +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.index.query.QueryBuilders; +import org.elasticsearch.search.SearchHit; +import org.elasticsearch.search.SearchHits; +import org.elasticsearch.search.builder.SearchSourceBuilder; + +@ApplicationScoped +public class ChatService { + @Inject + RestHighLevelClient restHighLevelClient; + + private Uni updateIndex(Chat chat) { + var request = new IndexRequest("chats"); + request.id(ChatId.toString(chat.id)); + request.source(JsonObject.mapFrom(chat).toString(), XContentType.JSON); + return restHighLevelClient.indexAsync(request, RequestOptions.DEFAULT); + } + + private Uni removeFromIndex(Long id) { + var request = new DeleteRequest("chats"); + request.id(ChatId.toString(id)); + return restHighLevelClient.deleteAsync(request, RequestOptions.DEFAULT); + } + + public Uni get(Long id) { + return Chat.findById(id); + } + + public Uni getFromIndex(Long id) { + GetRequest getRequest = new GetRequest("chats", ChatId.toString(id)); + return restHighLevelClient.getAsync(getRequest, RequestOptions.DEFAULT) + .map(getResponse -> { + if (getResponse.isExists()) { + String sourceAsString = getResponse.getSourceAsString(); + JsonObject json = new JsonObject(sourceAsString); + return json.mapTo(Chat.class); + } + return null; + }); + } + + public Uni create(Chat chat) { + return Panache.withTransaction(() -> Chat.persist(chat).replaceWith(updateIndex(chat))) + .replaceWithVoid(); + } + + public Uni delete(Long id) { + return Panache.withTransaction(() -> Chat.findById(id) + .onItem().ifNull().failWith(NotFoundException::new) + .flatMap(PanacheEntityBase::delete) + .replaceWith(removeFromIndex(id)) + .onItem().transform(DeleteResponse::status) + .replaceWithVoid() + ); + } + + public Uni update(Long id, Chat chat) { + if (chat.id != null && id != null && !Objects.equals(chat.id, id)) { + throw new IllegalArgumentException("Chat id is different than id"); + } + // Find chat by id + return Panache.withTransaction(() -> Chat.findById(id) + .flatMap(entity -> { + if (entity == null) { + // Persist the chat if not found + return Chat.persist(chat) + // Return the chat + .replaceWith(chat); + } else { + // Update all fields + entity.name = chat.name; + // Return the updated item + return Uni.createFrom().item(entity); + } + }) + // Update index + .onItem().transformToUni(updatedChat -> updateIndex(updatedChat).replaceWith(updatedChat)) + ); + } + + public Uni> searchByUsername(String username) { + return search("username", username); + } + + public Uni> searchByName(String name) { + return search("name", name); + } + + public Uni resolveByUsername(String username) { + return Chat.findUsername(username); + } + + private Uni> search(String term, String match) { + SearchRequest searchRequest = new SearchRequest("chats"); + SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); + searchSourceBuilder.query(QueryBuilders.matchQuery(term, match)); + searchRequest.source(searchSourceBuilder); + + return restHighLevelClient.searchAsync(searchRequest, RequestOptions.DEFAULT) + .map(searchResponse -> { + SearchHits hits = searchResponse.getHits(); + List results = new ArrayList<>(hits.getHits().length); + for (SearchHit hit : hits.getHits()) { + String sourceAsString = hit.getSourceAsString(); + JsonObject json = new JsonObject(sourceAsString); + results.add(json.mapTo(Chat.class)); + } + return results; + }); + } + + public Multi listAll() { + return Chat.streamAll(); + } + + public Uni count() { + return Chat.count(); + } +} diff --git a/service-chats/src/main/resources/application.properties b/service-chats/src/main/resources/application.properties index ba1d2f5..101cee8 100644 --- a/service-chats/src/main/resources/application.properties +++ b/service-chats/src/main/resources/application.properties @@ -1,4 +1,6 @@ quarkus.http.port=8282 +# we don't need SSL here, let's disable it to have a more compact native executable +quarkus.ssl.native=false %prod.quarkus.datasource.db-kind=postgresql %prod.quarkus.datasource.username=quarkus_test @@ -10,3 +12,6 @@ quarkus.hibernate-orm.sql-load-script=import.sql # Reactive config %prod.quarkus.datasource.reactive.url=vertx-reactive:postgresql://localhost/quarkus_test + +quarkus.elasticsearch.health.enabled=false +quarkus.elasticsearch.reactive.health.enabled=true diff --git a/service-chats/src/main/resources/import.sql b/service-chats/src/main/resources/import.sql index fb6d842..fe65eb5 100644 --- a/service-chats/src/main/resources/import.sql +++ b/service-chats/src/main/resources/import.sql @@ -1,4 +1,4 @@ -INSERT INTO Chat(id, name, username, status) VALUES (9007199256673076, 'My Supergroup', 'mysupergroup', 1); -INSERT INTO Chat(id, name, username, status) VALUES (777000, 'Telegram', 'telegram', null); -INSERT INTO Chat(id, name, username, status) VALUES (4503599627464345, 'School group', null, 1); -INSERT INTO Chat(id, name, username, status) VALUES (4503599627382355, 'Old school group', null, 0); +INSERT INTO chat(id, name, username, status) VALUES (9007199256673076, 'My Supergroup', 'mysupergroup', 1); +INSERT INTO chat(id, name, username, status) VALUES (777000, 'Telegram', 'telegram', null); +INSERT INTO chat(id, name, username, status) VALUES (4503599627464345, 'School group', null, 1); +INSERT INTO chat(id, name, username, status) VALUES (4503599627382355, 'Old school group', null, 0); diff --git a/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTest.java b/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTest.java index f14d795..e607754 100644 --- a/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTest.java +++ b/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTest.java @@ -2,22 +2,22 @@ package io.volvox.chats; import static io.restassured.RestAssured.given; import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.is; import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; import io.restassured.response.Response; -import javax.inject.Inject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @QuarkusTest +@QuarkusTestResource(ElasticsearchContainerTestResource.class) public class ChatsEndpointTest { - @Inject - ChatRepository chatRepository; - @Test public void testListAllChats() { //List all, should have all 3 usernames the database has initially: @@ -123,8 +123,21 @@ public class ChatsEndpointTest { .statusCode(500); } + @Test + public void testHealth() { + given() + .when() + .get("/q/health/ready") + .then() + .statusCode(200) + .contentType("application/json") + .body("status", is("UP"), + "checks.status", containsInAnyOrder("UP"), + "checks.name", containsInAnyOrder("Elasticsearch cluster health check")); + } + @BeforeEach public void tearDown(){ - Panache.withTransaction(() -> chatRepository.deleteById(777234L)).await().indefinitely(); + Panache.withTransaction(() -> Chat.deleteById(777234L)).await().indefinitely(); } } diff --git a/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTestIT.java b/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTestIT.java new file mode 100644 index 0000000..0b689b1 --- /dev/null +++ b/service-chats/src/test/java/io/volvox/chats/ChatsEndpointTestIT.java @@ -0,0 +1,9 @@ +package io.volvox.chats; + +import io.quarkus.test.junit.NativeImageTest; + +@NativeImageTest +public class ChatsEndpointTestIT extends ChatsEndpointTest { + + // Execute the same tests but in native mode. +} diff --git a/service-chats/src/test/java/io/volvox/chats/ElasticsearchContainerTestResource.java b/service-chats/src/test/java/io/volvox/chats/ElasticsearchContainerTestResource.java new file mode 100644 index 0000000..834fb73 --- /dev/null +++ b/service-chats/src/test/java/io/volvox/chats/ElasticsearchContainerTestResource.java @@ -0,0 +1,28 @@ +package io.volvox.chats; + +import java.util.Map; + +import org.testcontainers.elasticsearch.ElasticsearchContainer; +import org.testcontainers.utility.DockerImageName; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class ElasticsearchContainerTestResource implements QuarkusTestResourceLifecycleManager { + + static ElasticsearchContainer elasticsearchContainer = new ElasticsearchContainer( + DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch-oss") + .withTag("7.10.2")); + + @Override + public Map start() { + elasticsearchContainer.withEnv("action.auto_create_index", "true"); + elasticsearchContainer.start(); + return Map.of( + "quarkus.elasticsearch.hosts", elasticsearchContainer.getHttpHostAddress()); + } + + @Override + public void stop() { + elasticsearchContainer.stop(); + } +}