diff --git a/README.md b/README.md index b2523b27..f67118e7 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ ------ I am developing this project as stages, and all such stages are documented under project - **Springy Store μServices** [wiki page](https://github.com/mohamed-taman/Springy-Store - -Microservices/wiki). Each of such stage will be a release in its owen, so you can go back and + **Springy Store μServices** [wiki page](https://github.com/mohamed-taman/Springy-Store-Microservices/wiki). Each of such stage will be a release in its owen, so you can go back and forward between releases to see the differences and how adding things solve specific problems we face. For example; in the first stage (1st release) I just created project structure, basic services' skeleton, integration between them, and finally write integration testing as well as semi-automated testing for the whole services' functionality. -At 1st stage the **recommendation** and **review** microservices generate local in-memory data and **product composite service** calls the other three services (*product*, *recommendation*, and *review*) statically to generate client aggregate response for a specific product. Therefore, in: +At 1st stage the **Recommendation** and **Review** microservices generate local in-memory data + and **Store Service** calls the other three services (*Product*, *Recommendation*, and *Review*) statically to generate client aggregate response for a specific product. Therefore, in: -- The second stage I will introduce **database integration**, then in -- The third stage I will introduce **Dockerization** of our services and **docker-compose**, and in +- The second stage I will introduce **database integration**, then in (***done***) +- The third stage I will introduce **Dockerization** of our services and **docker-compose**, and in (***done***) - The fourth stage I will introduce **service discovery**, and so on. ## Getting started @@ -33,19 +33,19 @@ The following topics are going to be covered in this 1st stage (other stages top - Introducing the microservice landscape. - Generating skeleton microservices. - Adding RESTful APIs. -- Adding a **product composite**, **product**, **recommendation**, and **review** microservices. +- Adding a **Store**, **Product**, **Recommendation**, and **Review** microservices. - Adding error handling. - Testing the APIs manually. - Adding automated tests of microservices in isolation. - Adding semi-automated tests to a microservice landscape. -### System Boundary - μServices Landscape +### System Boundary - μServices Landscape (Release 3) ![System Boundary](docs/stage1/app_ms_landscape.png) ### Required software -The following software pieces are initially required: +The following are the initially required software pieces: 1. **Git**: it can be downloaded and installed from https://git-scm.com/downloads. @@ -57,6 +57,8 @@ The following software pieces are initially required: 5. Spring Boot Initializer: This *Initializer* generates *spring* boot project with just what you need to start quickly! start from here https://start.spring.io/. +6. **Docker Desktop**: The fastest way to containerize applications on your desktop, and you can download it from here [https://www.docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop) + > For each future stage, I will list the newly required software. Follow the installation guide for each software website and check your software versions from the command line to verify that they are installed correctly. @@ -85,7 +87,9 @@ To build and run test cases for each service & shared modules in the project we > This done only for the first time or any new version of shared modules. -To build and install `store-utils`, `store-api`, `store-chassis` libraries, from the root folder `springy-store-microservices` run the following commands: +To build and install `store-build-chassis`, `store-utils`, `store-api`, `store-chassis` libraries +, from the root + folder `springy-store-microservices` run the following commands: ```bash mohamed.taman@DTLNV8 ~/springy-store-microservices @@ -98,16 +102,16 @@ Now you should expect output like this: Installing all Springy store core shared modules ................................................ -1- Installing [build parent] module... +1- Installing [Parent Build Chassis] module... Done successfully. -2- Installing shared [Utilities] module... +2- Installing shared [Services Utilities] module... Done successfully. -3- Installing shared [APIs] module... +3- Installing shared [Services APIs] module... Done successfully. -4- Installing [service parent] module... +4- Installing [Services Parent Chassis] module... Done successfully. Woohoo, building & installing all project modules are finished successfully. @@ -120,7 +124,7 @@ Now it is time to build our **4 microservices** and run each service integration ```bash mohamed.taman@DTLNV8 ~/springy-store-microservices -λ ./mvnw clean verify +λ ./mvnw clean verify -Ddockerfile.skip ``` All build commands and test suite for each microservice should run successfully, and the final output should be like this: @@ -138,7 +142,7 @@ All build commands and test suite for each microservice should run successfully, [INFO] Springy Store APIs ................................. SUCCESS [ 3.920 s] [INFO] Springy Store Utils ................................ SUCCESS [ 1.508 s] [INFO] Springy Store Chassis .............................. SUCCESS [ 0.608 s] -[INFO] Product Composite Service .......................... SUCCESS [ 4.073 s] +[INFO] Store Service ...................................... SUCCESS [ 4.073 s] [INFO] Product Service .................................... SUCCESS [ 2.710 s] [INFO] Review Service ..................................... SUCCESS [ 2.633 s] [INFO] Recommendation Service ............................. SUCCESS [ 2.615 s] @@ -152,22 +156,36 @@ All build commands and test suite for each microservice should run successfully, ``` ### Running Them All -Now it's the time to run all of them, and it's very simple just run the following two commands: +Now it's the time to run all of them, and it's very simple just run the following *docker compose* commands: ```bash mohamed.taman@DTLNV8 ~/springy-store-microservices -λ ./run-em-all.sh +λ docker-compose -p ssm up -d +``` + +All the **services** and **databases** will run in parallel in detached mode (option `-d`), and their output will be printed to the console as the following: + +```bash +Creating network "ssm_default" with the default driver +Creating ssm_mysql_1 ... done +Creating ssm_mongodb_1 ... done +Creating ssm_store_1 ... done +Creating ssm_review_1 ... done +Creating ssm_product_1 ... done +Creating ssm_recommendation_1 ... done ``` -All the services will run in parallel, and their output will be printed to the console. +### Access Store APIs +You can manually test `Store Service` APIs through out its **Swagger** interface at the following + URL [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html). ### Testing Them All -Now it's time to test all functionality of the application as one part. To do so just run +Now it's time to test all the application functionality as one part. To do so just run the following automation test script: ```bash mohamed.taman@DTLNV8 ~/springy-store-microservices -λ PORT=9080 ./test-em-all.sh +λ ./test-em-all.sh ``` The result should be something like this: @@ -175,14 +193,18 @@ The result should be something like this: ```bash Starting [Springy Store] full functionality testing.... +Start: Sun, Apr 12, 2020 2:34:19 PM HOST=localhost -PORT=9080 +PORT=8080 +Wait for: curl -X DELETE http://localhost:8080/store/api/v1/products/13... Ok +Test OK (HTTP Code: 200) +Test OK (HTTP Code: 200) +Test OK (HTTP Code: 200) Test OK (HTTP Code: 200) Test OK (actual value: 1) Test OK (actual value: 3) Test OK (actual value: 3) -Test OK (HTTP Code: 404, {"httpStatus":"NOT_FOUND","message":"No product found for productId: 13 -","path":"/v1/product-composite/13","time":"2020-04-01@14:51:48.812+0200"}) +Test OK (HTTP Code: 404, {"httpStatus":"NOT_FOUND","message":"No product found for productId: 13","path":"/store/api/v1/products/13","time":"2020-04-12@12:34:25.144+0000"}) Test OK (HTTP Code: 200) Test OK (actual value: 113) Test OK (actual value: 0) @@ -191,12 +213,11 @@ Test OK (HTTP Code: 200) Test OK (actual value: 213) Test OK (actual value: 3) Test OK (actual value: 0) -Test OK (HTTP Code: 422, {"httpStatus":"UNPROCESSABLE_ENTITY","message":"Invalid productId: -1 -","path":"/v1/product-composite/-1","time":"2020-04-01@14:51:49.763+0200"}) +Test OK (HTTP Code: 422, {"httpStatus":"UNPROCESSABLE_ENTITY","message":"Invalid productId: -1","path":"/store/api/v1/products/-1","time":"2020-04-12@12:34:26.243+0000"}) Test OK (actual value: "Invalid productId: -1") -Test OK (HTTP Code: 400, {"timestamp":"2020-04-01T12:51:49.965+0000","path":"/v1/product-composite -/invalidProductId","status":400,"error":"Bad Request","message":"Type mismatch."}) +Test OK (HTTP Code: 400, {"timestamp":"2020-04-12T12:34:26.471+00:00","path":"/store/api/v1/products/invalidProductId","status":400,"error":"Bad Request","message":"Type mismatch.","requestId":"044dcdf2-13"}) Test OK (actual value: "Type mismatch.") +End: Sun, Apr 12, 2020 2:34:26 PM ``` ### Closing The Story @@ -206,30 +227,25 @@ Finally, to close the story, we will need to shut down Microservices manually se ```bash mohamed.taman@DTLNV8 ~/springy-store-microservices -λ ./stop-em-all.sh +λ docker-compose -p ssm down ``` And the output should be as the following: ```bash -Stopping [Springy Store] μServices .... ---------------------------------------- - -Stopping Microservice at port 9080 .... -{"message":"Shutting down, bye..."} -Microservice at port 9080 stopped successfully .... - -Stopping Microservice at port 9081 .... -{"message":"Shutting down, bye..."} -Microservice at port 9081 stopped successfully .... - -Stopping Microservice at port 9082 .... -{"message":"Shutting down, bye..."} -Microservice at port 9082 stopped successfully .... - -Stopping Microservice at port 9083 .... -{"message":"Shutting down, bye..."} -Microservice at port 9083 stopped successfully .... +Stopping ssm_recommendation_1 ... done +Stopping ssm_product_1 ... done +Stopping ssm_review_1 ... done +Stopping ssm_mongodb_1 ... done +Stopping ssm_store_1 ... done +Stopping ssm_mysql_1 ... done +Removing ssm_recommendation_1 ... done +Removing ssm_product_1 ... done +Removing ssm_review_1 ... done +Removing ssm_mongodb_1 ... done +Removing ssm_store_1 ... done +Removing ssm_mysql_1 ... done +Removing network ssm_default ``` ### The End diff --git a/docker-compose.yml b/docker-compose.yml index 9e3972c9..aaba381c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,58 @@ -version: '2.1' +version: '3.7' ## Latest version works with Docker Engine release 18.06.0+ services: product: build: product-service - mem_limit: 350m environment: - SPRING_PROFILES_ACTIVE=docker + depends_on: + - mongodb recommendation: build: recommendation-service - mem_limit: 350m environment: - SPRING_PROFILES_ACTIVE=docker + depends_on: + - mongodb review: build: review-service - mem_limit: 350m environment: - SPRING_PROFILES_ACTIVE=docker + depends_on: + - mysql - product-composite: - build: product-composite-service - mem_limit: 350m + store: + build: store-service ports: - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=docker + + # $ mongo + mongodb: + image: mongo:4.2.5-bionic + ports: + - "27017-27019:27017-27019" + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongo mongo:27017/test --quiet 1 + interval: 10s + timeout: 10s + retries: 5 + start_period: 40s + + # $ mysql -uroot -h127.0.0.1 -p + mysql: + image: mysql:8.0.19 + ports: + - "3306:3306" + environment: + - MYSQL_ROOT_PASSWORD=rootpwd + - MYSQL_DATABASE=review-db + - MYSQL_USER=user + - MYSQL_PASSWORD=pwd + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-uuser", "-ppwd", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 10 diff --git a/docs/stage1/app_ms_landscape.png b/docs/stage1/app_ms_landscape.png index 6ed5030d..cc328749 100644 Binary files a/docs/stage1/app_ms_landscape.png and b/docs/stage1/app_ms_landscape.png differ diff --git a/pom.xml b/pom.xml index 9ebd5f3d..b02a4ce5 100644 --- a/pom.xml +++ b/pom.xml @@ -16,12 +16,11 @@ true - - product-composite-service product-service review-service recommendation-service + store-service store-api store-utils store-build-chassis diff --git a/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/controller/ProductCompositeServiceImpl.java b/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/controller/ProductCompositeServiceImpl.java deleted file mode 100644 index 01724914..00000000 --- a/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/controller/ProductCompositeServiceImpl.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.siriusxi.ms.store.pcs.controller; - -import com.siriusxi.ms.store.api.composite.product.ProductCompositeService; -import com.siriusxi.ms.store.api.composite.product.dto.ProductAggregate; -import com.siriusxi.ms.store.api.composite.product.dto.RecommendationSummary; -import com.siriusxi.ms.store.api.composite.product.dto.ReviewSummary; -import com.siriusxi.ms.store.api.composite.product.dto.ServiceAddresses; -import com.siriusxi.ms.store.api.core.product.Product; -import com.siriusxi.ms.store.api.core.recommendation.Recommendation; -import com.siriusxi.ms.store.api.core.review.Review; -import com.siriusxi.ms.store.pcs.integration.ProductCompositeIntegration; -import com.siriusxi.ms.store.util.exceptions.NotFoundException; -import com.siriusxi.ms.store.util.http.ServiceUtil; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; -import java.util.stream.Collectors; - -@RestController -public class ProductCompositeServiceImpl implements ProductCompositeService { - - private final ServiceUtil serviceUtil; - private final ProductCompositeIntegration integration; - - @Autowired - public ProductCompositeServiceImpl(ServiceUtil serviceUtil, ProductCompositeIntegration integration) { - this.serviceUtil = serviceUtil; - this.integration = integration; - } - - @Override - public ProductAggregate getProduct(int productId) { - - var product = integration.getProduct(productId); - if (product == null) - throw new NotFoundException("No product found for productId: " + productId); - - var recommendations = integration.getRecommendations(productId); - - var reviews = integration.getReviews(productId); - - return createProductAggregate(product, recommendations, reviews, serviceUtil.getServiceAddress()); - } - - private ProductAggregate createProductAggregate(Product product, List recommendations, - List reviews, String serviceAddress) { - - // 1. Setup product info - int productId = product.getProductId(); - String name = product.getName(); - int weight = product.getWeight(); - - // 2. Copy summary recommendation info, if available - List recommendationSummaries = (recommendations == null) ? null : - recommendations.stream() - .map(r -> new RecommendationSummary(r.getRecommendationId(), r.getAuthor(), r.getRate())) - .collect(Collectors.toList()); - - // 3. Copy summary review info, if available - List reviewSummaries = (reviews == null) ? null : - reviews.stream() - .map(r -> new ReviewSummary(r.getReviewId(), r.getAuthor(), r.getSubject())) - .collect(Collectors.toList()); - - // 4. Create info regarding the involved microservices addresses - String productAddress = product.getServiceAddress(); - String reviewAddress = (reviews != null && !reviews.isEmpty()) ? reviews.get(0).getServiceAddress() : ""; - String recommendationAddress = (recommendations != null && !recommendations.isEmpty()) ? - recommendations.get(0).getServiceAddress() : ""; - ServiceAddresses serviceAddresses = new ServiceAddresses( - serviceAddress, - productAddress, - reviewAddress, - recommendationAddress); - - return new ProductAggregate( - productId, - name, - weight, - recommendationSummaries, - reviewSummaries, - serviceAddresses); - } -} diff --git a/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/integration/ProductCompositeIntegration.java b/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/integration/ProductCompositeIntegration.java deleted file mode 100644 index 3073edb5..00000000 --- a/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/integration/ProductCompositeIntegration.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.siriusxi.ms.store.pcs.integration; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.siriusxi.ms.store.api.core.product.Product; -import com.siriusxi.ms.store.api.core.product.ProductService; -import com.siriusxi.ms.store.api.core.recommendation.Recommendation; -import com.siriusxi.ms.store.api.core.recommendation.RecommendationService; -import com.siriusxi.ms.store.api.core.review.Review; -import com.siriusxi.ms.store.api.core.review.ReviewService; -import com.siriusxi.ms.store.util.exceptions.InvalidInputException; -import com.siriusxi.ms.store.util.exceptions.NotFoundException; -import com.siriusxi.ms.store.util.http.HttpErrorInfo; -import lombok.extern.log4j.Log4j2; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.stereotype.Component; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.RestTemplate; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import static java.lang.String.valueOf; -import static org.springframework.http.HttpMethod.GET; - -@Component -@Log4j2 -public class ProductCompositeIntegration - implements - ProductService, - RecommendationService, - ReviewService { - - private final RestTemplate restTemplate; - private final ObjectMapper mapper; - - private final String productServiceUrl; - private final String recommendationServiceUrl; - private final String reviewServiceUrl; - - @Autowired - public ProductCompositeIntegration( - RestTemplate restTemplate, - ObjectMapper mapper, - - @Value("${app.product-service.host}") String productServiceHost, - @Value("${app.product-service.port}") int productServicePort, - - @Value("${app.recommendation-service.host}") String recommendationServiceHost, - @Value("${app.recommendation-service.port}") int recommendationServicePort, - - @Value("${app.review-service.host}") String reviewServiceHost, - @Value("${app.review-service.port}") int reviewServicePort - ) { - - this.restTemplate = restTemplate; - this.mapper = mapper; - - var http = "http://"; - productServiceUrl = http.concat(productServiceHost).concat(":").concat(valueOf(productServicePort)) - .concat("/product/"); - recommendationServiceUrl = http.concat(recommendationServiceHost).concat(":") - .concat(valueOf(recommendationServicePort)).concat("/recommendation?productId="); - reviewServiceUrl = http.concat(reviewServiceHost).concat(":").concat(valueOf(reviewServicePort)) - .concat("/review?productId="); - } - - @Override - public Product getProduct(int productId) { - - try { - String url = productServiceUrl + productId; - log.debug("Will call getProduct API on URL: {}", url); - - Product product = restTemplate.getForObject(url, Product.class); - log.debug("Found a product with id: {}", product != null ? product.getProductId() : "No Product found!!"); - - return product; - - } catch (HttpClientErrorException ex) { - - switch (ex.getStatusCode()) { - case NOT_FOUND -> throw new NotFoundException(getErrorMessage(ex)); - case UNPROCESSABLE_ENTITY -> throw new InvalidInputException(getErrorMessage(ex)); - default -> { - log.warn("Got a unexpected HTTP error: {}, will rethrow it", ex.getStatusCode()); - log.warn("Error body: {}", ex.getResponseBodyAsString()); - throw ex; - } - } - } - } - - @Override - public List getRecommendations(int productId) { - - try { - String url = recommendationServiceUrl + productId; - - log.debug("Will call getRecommendations API on URL: {}", url); - List recommendations = restTemplate - .exchange(url, GET, null, - new ParameterizedTypeReference>() { - }) - .getBody(); - - log.debug("Found {} recommendations for a product with id: {}", - recommendations != null ? recommendations.size() : "{No Recommendations}", - productId); - - return recommendations; - - } catch (Exception ex) { - log.warn("Got an exception while requesting recommendations, return zero recommendations: {}", - ex.getMessage()); - - return new ArrayList<>(); - } - } - - @Override - public List getReviews(int productId) { - - try { - String url = reviewServiceUrl + productId; - - log.debug("Will call getReviews API on URL: {}", url); - List reviews = restTemplate.exchange( - url, - GET, - null, - new ParameterizedTypeReference>() { - }) - .getBody(); - - log.debug("Found {} reviews for a product with id: {}", - reviews != null ? reviews.size() : "{No Reviews}", - productId); - - return reviews; - - } catch (Exception ex) { - log.warn("Got an exception while requesting reviews, return zero reviews: {}", ex.getMessage()); - return new ArrayList<>(); - } - } - - private String getErrorMessage(HttpClientErrorException ex) { - try { - return mapper.readValue(ex.getResponseBodyAsString(), HttpErrorInfo.class).message(); - } catch (IOException ioex) { - return ex.getMessage(); - } - } -} diff --git a/product-composite-service/src/test/java/com/siriusxi/ms/store/pcs/ProductCompositeServiceApplicationTests.java b/product-composite-service/src/test/java/com/siriusxi/ms/store/pcs/ProductCompositeServiceApplicationTests.java deleted file mode 100644 index 2e324c43..00000000 --- a/product-composite-service/src/test/java/com/siriusxi/ms/store/pcs/ProductCompositeServiceApplicationTests.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.siriusxi.ms.store.pcs; - -import com.siriusxi.ms.store.api.core.product.Product; -import com.siriusxi.ms.store.api.core.recommendation.Recommendation; -import com.siriusxi.ms.store.api.core.review.Review; -import com.siriusxi.ms.store.pcs.integration.ProductCompositeIntegration; -import com.siriusxi.ms.store.util.exceptions.InvalidInputException; -import com.siriusxi.ms.store.util.exceptions.NotFoundException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.web.reactive.server.WebTestClient; - -import static java.util.Collections.singletonList; -import static org.mockito.Mockito.when; -import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; -import static org.springframework.http.MediaType.APPLICATION_JSON; - -@SpringBootTest(webEnvironment = RANDOM_PORT) -class ProductCompositeServiceApplicationTests { - - private static final int PRODUCT_ID_OK = 1; - private static final int PRODUCT_ID_NOT_FOUND = 2; - private static final int PRODUCT_ID_INVALID = 3; - public static final String BASE_URL = "/v1/product-composite/"; - - - @Autowired - private WebTestClient client; - - @MockBean - private ProductCompositeIntegration compositeIntegration; - - @BeforeEach - void setUp() { - - when(compositeIntegration.getProduct(PRODUCT_ID_OK)). - thenReturn(new Product(PRODUCT_ID_OK, "name", 1, "mock-address")); - - when(compositeIntegration.getRecommendations(PRODUCT_ID_OK)). - thenReturn(singletonList(new Recommendation(PRODUCT_ID_OK, - 1, "author", - 1, "content", "mock address"))); - - when(compositeIntegration.getReviews(PRODUCT_ID_OK)). - thenReturn(singletonList(new Review(PRODUCT_ID_OK, 1, - "author", "subject", "content", - "mock address"))); - - when(compositeIntegration.getProduct(PRODUCT_ID_NOT_FOUND)) - .thenThrow(new NotFoundException("NOT FOUND: " + PRODUCT_ID_NOT_FOUND)); - - when(compositeIntegration.getProduct(PRODUCT_ID_INVALID)) - .thenThrow(new InvalidInputException("INVALID: " + PRODUCT_ID_INVALID)); - } - - @Test - public void getProductById() { - - client.get() - .uri(BASE_URL + PRODUCT_ID_OK) - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isOk() - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.productId").isEqualTo(PRODUCT_ID_OK) - .jsonPath("$.recommendations.length()").isEqualTo(1) - .jsonPath("$.reviews.length()").isEqualTo(1); - } - - @Test - public void getProductNotFound() { - - client.get() - .uri(BASE_URL + PRODUCT_ID_NOT_FOUND) - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isNotFound() - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo(BASE_URL + PRODUCT_ID_NOT_FOUND) - .jsonPath("$.message").isEqualTo("NOT FOUND: " + PRODUCT_ID_NOT_FOUND); - } - - @Test - public void getProductInvalidInput() { - - client.get() - .uri(BASE_URL + PRODUCT_ID_INVALID) - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isEqualTo(UNPROCESSABLE_ENTITY) - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo(BASE_URL + PRODUCT_ID_INVALID) - .jsonPath("$.message").isEqualTo("INVALID: " + PRODUCT_ID_INVALID); - } - -} diff --git a/product-service/pom.xml b/product-service/pom.xml index f6665f5d..664ee112 100644 --- a/product-service/pom.xml +++ b/product-service/pom.xml @@ -16,4 +16,21 @@ 1.0-SNAPSHOT Product Service Spring Boot based project jar + + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + + de.flapdoodle.embed + de.flapdoodle.embed.mongo + test + + + diff --git a/product-service/src/main/java/com/siriusxi/ms/store/ps/ProductServiceApplication.java b/product-service/src/main/java/com/siriusxi/ms/store/ps/ProductServiceApplication.java index 9c3d985f..1c88f122 100644 --- a/product-service/src/main/java/com/siriusxi/ms/store/ps/ProductServiceApplication.java +++ b/product-service/src/main/java/com/siriusxi/ms/store/ps/ProductServiceApplication.java @@ -1,15 +1,22 @@ package com.siriusxi.ms.store.ps; +import lombok.extern.log4j.Log4j2; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.ComponentScan; @SpringBootApplication @ComponentScan("com.siriusxi.ms.store") +@Log4j2 public class ProductServiceApplication { public static void main(String[] args) { - SpringApplication.run(ProductServiceApplication.class, args); + ConfigurableApplicationContext ctx = SpringApplication.run(ProductServiceApplication.class, args); + + String mongodDbHost = ctx.getEnvironment().getProperty("spring.data.mongodb.host"); + String mongodDbPort = ctx.getEnvironment().getProperty("spring.data.mongodb.port"); + log.info("Connected to MongoDb: " + mongodDbHost + ":" + mongodDbPort); } } diff --git a/product-service/src/main/java/com/siriusxi/ms/store/ps/controller/ProductMapper.java b/product-service/src/main/java/com/siriusxi/ms/store/ps/controller/ProductMapper.java new file mode 100644 index 00000000..25a34994 --- /dev/null +++ b/product-service/src/main/java/com/siriusxi/ms/store/ps/controller/ProductMapper.java @@ -0,0 +1,20 @@ +package com.siriusxi.ms.store.ps.controller; + +import com.siriusxi.ms.store.api.core.product.Product; +import com.siriusxi.ms.store.ps.persistence.ProductEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper(componentModel = "spring") +public interface ProductMapper { + + ProductMapper INSTANCE = Mappers.getMapper( ProductMapper.class); + + @Mapping(target = "serviceAddress", ignore = true) + Product entityToApi(ProductEntity entity); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "version", ignore = true) + ProductEntity apiToEntity(Product api); +} diff --git a/product-service/src/main/java/com/siriusxi/ms/store/ps/controller/ProductServiceImpl.java b/product-service/src/main/java/com/siriusxi/ms/store/ps/controller/ProductServiceImpl.java index 7458ccf2..c2152311 100644 --- a/product-service/src/main/java/com/siriusxi/ms/store/ps/controller/ProductServiceImpl.java +++ b/product-service/src/main/java/com/siriusxi/ms/store/ps/controller/ProductServiceImpl.java @@ -1,7 +1,10 @@ package com.siriusxi.ms.store.ps.controller; +import com.mongodb.DuplicateKeyException; import com.siriusxi.ms.store.api.core.product.Product; import com.siriusxi.ms.store.api.core.product.ProductService; +import com.siriusxi.ms.store.ps.persistence.ProductEntity; +import com.siriusxi.ms.store.ps.persistence.ProductRepository; import com.siriusxi.ms.store.util.exceptions.InvalidInputException; import com.siriusxi.ms.store.util.exceptions.NotFoundException; import com.siriusxi.ms.store.util.http.ServiceUtil; @@ -15,20 +18,51 @@ public class ProductServiceImpl implements ProductService { private final ServiceUtil serviceUtil; + private final ProductRepository repository; + + private final ProductMapper mapper; + @Autowired - public ProductServiceImpl(ServiceUtil serviceUtil) { + public ProductServiceImpl(ProductRepository repository, + ProductMapper mapper, + ServiceUtil serviceUtil) { + this.repository = repository; + this.mapper = mapper; this.serviceUtil = serviceUtil; } @Override - public Product getProduct(int productId) { - log.debug("/product returns the found product for productId={}", productId); + public Product createProduct(Product body) { + try { + ProductEntity entity = mapper.apiToEntity(body); + ProductEntity newEntity = repository.save(entity); + + log.debug("createProduct: entity created for productId: {}", body.getProductId()); + return mapper.entityToApi(newEntity); + } catch (DuplicateKeyException dke) { + throw new InvalidInputException("Duplicate key, Product Id: " + body.getProductId()); + } + } + + @Override + public Product getProduct(int productId) { if (productId < 1) throw new InvalidInputException("Invalid productId: " + productId); - if (productId == 13) - throw new NotFoundException("No product found for productId: " + productId); + ProductEntity entity = repository.findByProductId(productId) + .orElseThrow(() -> new NotFoundException("No product found for productId: " + productId)); + + Product response = mapper.entityToApi(entity); + response.setServiceAddress(serviceUtil.getServiceAddress()); + + log.debug("getProduct: found productId: {}", response.getProductId()); - return new Product(productId, "name-" + productId, 123, serviceUtil.getServiceAddress()); + return response; + } + + @Override + public void deleteProduct(int productId) { + log.debug("deleteProduct: tries to delete an entity with productId: {}", productId); + repository.findByProductId(productId).ifPresent(repository::delete); } } diff --git a/product-service/src/main/java/com/siriusxi/ms/store/ps/persistence/ProductEntity.java b/product-service/src/main/java/com/siriusxi/ms/store/ps/persistence/ProductEntity.java new file mode 100644 index 00000000..6e79fe2c --- /dev/null +++ b/product-service/src/main/java/com/siriusxi/ms/store/ps/persistence/ProductEntity.java @@ -0,0 +1,32 @@ +package com.siriusxi.ms.store.ps.persistence; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Version; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection="products") +@Data +@NoArgsConstructor +public class ProductEntity { + @Id + private String id; + + @Version + private Integer version; + + @Indexed(unique = true) + private int productId; + + private String name; + + private int weight; + + public ProductEntity(int productId, String name, int weight) { + this.productId = productId; + this.name = name; + this.weight = weight; + } +} diff --git a/product-service/src/main/java/com/siriusxi/ms/store/ps/persistence/ProductRepository.java b/product-service/src/main/java/com/siriusxi/ms/store/ps/persistence/ProductRepository.java new file mode 100644 index 00000000..250ee046 --- /dev/null +++ b/product-service/src/main/java/com/siriusxi/ms/store/ps/persistence/ProductRepository.java @@ -0,0 +1,12 @@ +package com.siriusxi.ms.store.ps.persistence; + +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ProductRepository extends PagingAndSortingRepository { + + Optional findByProductId(int productId); +} diff --git a/product-service/src/main/resources/application.yaml b/product-service/src/main/resources/application.yaml index e14a686a..0c7cdc9d 100644 --- a/product-service/src/main/resources/application.yaml +++ b/product-service/src/main/resources/application.yaml @@ -1,6 +1,11 @@ spring: application: name: product-service + data: + mongodb: + host: localhost + port: 27017 + database: product-db server: port: 9081 @@ -9,7 +14,8 @@ logging: level: web: DEBUG root: INFO - com.siriusxi.ms:.store: DEBUG + com.siriusxi.ms.store: DEBUG + org.springframework.data.mongodb.core.MongoTemplate: DEBUG management: endpoints: @@ -28,6 +34,9 @@ spring: profiles: docker jmx: enabled: false + data: + mongodb: + host: mongodb server: port: 8080 \ No newline at end of file diff --git a/product-service/src/main/resources/banner.txt b/product-service/src/main/resources/banner.txt new file mode 100644 index 00000000..dc04004f --- /dev/null +++ b/product-service/src/main/resources/banner.txt @@ -0,0 +1,13 @@ + + _____ _ _ __ _______ _____ _ _____ _ ______ + / ____(_) (_) \ \ / /_ _| / ____| (_) / ____| | \ \ \ \ +| (___ _ _ __ _ _ _ ___ \ V / | | ______ | (___ _ __ _ __ _ _ __ __ _ _ _ | (___ | |_ ___ _ __ ___ \ \ \ \ + \___ \| | '__| | | | / __| > < | | |______| \___ \| '_ \| '__| | '_ \ / _` | | | | \___ \| __/ _ \| '__/ _ \ > > > > + ____) | | | | | |_| \__ \/ . \ _| |_ ____) | |_) | | | | | | | (_| | |_| | ____) | || (_) | | | __// / / / +|_____/|_|_| |_|\__,_|___/_/ \_\_____| |_____/| .__/|_| |_|_| |_|\__, |\__, | |_____/ \__\___/|_| \___/_/_/_/ + | | __/ | __/ | + |_| |___/ |___/ + +:: ${application.title} (Microservice) :: v${application.version} :: Built with Spring Boot :: v${spring-boot.version} + + diff --git a/product-service/src/test/java/com/siriusxi/ms/store/ps/MapperTests.java b/product-service/src/test/java/com/siriusxi/ms/store/ps/MapperTests.java new file mode 100644 index 00000000..7933fd07 --- /dev/null +++ b/product-service/src/test/java/com/siriusxi/ms/store/ps/MapperTests.java @@ -0,0 +1,36 @@ +package com.siriusxi.ms.store.ps; + +import com.siriusxi.ms.store.api.core.product.Product; +import com.siriusxi.ms.store.ps.controller.ProductMapper; +import com.siriusxi.ms.store.ps.persistence.ProductEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class MapperTests { + + private final ProductMapper mapper = ProductMapper.INSTANCE; + + @Test + public void mapperTests() { + + assertNotNull(mapper); + + Product api = new Product(1, "n", 1, "sa"); + + ProductEntity entity = mapper.apiToEntity(api); + + assertEquals(api.getProductId(), entity.getProductId()); + assertEquals(api.getProductId(), entity.getProductId()); + assertEquals(api.getName(), entity.getName()); + assertEquals(api.getWeight(), entity.getWeight()); + + Product api2 = mapper.entityToApi(entity); + + assertEquals(api.getProductId(), api2.getProductId()); + assertEquals(api.getProductId(), api2.getProductId()); + assertEquals(api.getName(), api2.getName()); + assertEquals(api.getWeight(), api2.getWeight()); + assertNull(api2.getServiceAddress()); + } +} diff --git a/product-service/src/test/java/com/siriusxi/ms/store/ps/PersistenceTests.java b/product-service/src/test/java/com/siriusxi/ms/store/ps/PersistenceTests.java new file mode 100644 index 00000000..87f5deb6 --- /dev/null +++ b/product-service/src/test/java/com/siriusxi/ms/store/ps/PersistenceTests.java @@ -0,0 +1,157 @@ +package com.siriusxi.ms.store.ps; + +import com.siriusxi.ms.store.ps.persistence.ProductEntity; +import com.siriusxi.ms.store.ps.persistence.ProductRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static java.util.stream.IntStream.rangeClosed; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.data.domain.Sort.Direction.ASC; + +// FIXME to fix all optional class check with isPresent() +@DataMongoTest +public class PersistenceTests { + + @Autowired private ProductRepository repository; + + private ProductEntity savedEntity; + + @BeforeEach + public void setupDb() { + repository.deleteAll(); + + ProductEntity entity = new ProductEntity(1, "n", 1); + savedEntity = repository.save(entity); + + assertEqualsProduct(entity, savedEntity); + } + + @Test + public void create() { + + ProductEntity newEntity = new ProductEntity(2, "n", 2); + repository.save(newEntity); + + ProductEntity foundEntity = repository.findById(newEntity.getId()).get(); + assertEqualsProduct(newEntity, foundEntity); + + assertEquals(2, repository.count()); + } + + @Test + public void update() { + savedEntity.setName("n2"); + repository.save(savedEntity); + + ProductEntity foundEntity = repository.findById(savedEntity.getId()).get(); + assertEquals(1, (long) foundEntity.getVersion()); + assertEquals("n2", foundEntity.getName()); + } + + @Test + public void delete() { + repository.delete(savedEntity); + assertFalse(repository.existsById(savedEntity.getId())); + } + + @Test + public void getByProductId() { + Optional entity = repository.findByProductId(savedEntity.getProductId()); + + assertTrue(entity.isPresent()); + assertEqualsProduct(savedEntity, entity.get()); + } + + //FIXME error which is not thrown + @Test + @Disabled + public void duplicateError() { + + Assertions.assertThrows( + DuplicateKeyException.class, + () -> { + ProductEntity entity = new ProductEntity(savedEntity.getProductId(), "n", 1); + repository.save(entity); + }); + } + + @Test + public void optimisticLockError() { + + // Store the saved entity in two separate entity objects + ProductEntity entity1 = repository.findById(savedEntity.getId()).get(); + ProductEntity entity2 = repository.findById(savedEntity.getId()).get(); + + // Update the entity using the first entity object + entity1.setName("n1"); + repository.save(entity1); + + // Update the entity using the second entity object. + // This should fail since the second entity now holds a old version number, i.e. a Optimistic + // Lock Error + try { + entity2.setName("n2"); + repository.save(entity2); + + fail("Expected an OptimisticLockingFailureException"); + } catch (OptimisticLockingFailureException ignored) { + } + + // Get the updated entity from the database and verify its new sate + ProductEntity updatedEntity = repository.findById(savedEntity.getId()).get(); + assertEquals(1, (int) updatedEntity.getVersion()); + assertEquals("n1", updatedEntity.getName()); + } + + @Test + public void paging() { + + repository.deleteAll(); + + List newProducts = + rangeClosed(1001, 1010) + .mapToObj(i -> new ProductEntity(i, "name " + i, i)) + .collect(Collectors.toList()); + repository.saveAll(newProducts); + + Pageable nextPage = PageRequest.of(0, 4, ASC, "productId"); + nextPage = testNextPage(nextPage, "[1001, 1002, 1003, 1004]", true); + nextPage = testNextPage(nextPage, "[1005, 1006, 1007, 1008]", true); + testNextPage(nextPage, "[1009, 1010]", false); + } + + private Pageable testNextPage( + Pageable nextPage, String expectedProductIds, boolean expectsNextPage) { + Page productPage = repository.findAll(nextPage); + assertEquals( + expectedProductIds, + productPage.getContent().stream() + .map(ProductEntity::getProductId) + .collect(Collectors.toList()) + .toString()); + assertEquals(expectsNextPage, productPage.hasNext()); + return productPage.nextPageable(); + } + + private void assertEqualsProduct(ProductEntity expectedEntity, ProductEntity actualEntity) { + assertEquals(expectedEntity.getId(), actualEntity.getId()); + assertEquals(expectedEntity.getVersion(), actualEntity.getVersion()); + assertEquals(expectedEntity.getProductId(), actualEntity.getProductId()); + assertEquals(expectedEntity.getName(), actualEntity.getName()); + assertEquals(expectedEntity.getWeight(), actualEntity.getWeight()); + } +} diff --git a/product-service/src/test/java/com/siriusxi/ms/store/ps/ProductServiceApplicationTests.java b/product-service/src/test/java/com/siriusxi/ms/store/ps/ProductServiceApplicationTests.java index 687a2ce3..ce7f24e1 100644 --- a/product-service/src/test/java/com/siriusxi/ms/store/ps/ProductServiceApplicationTests.java +++ b/product-service/src/test/java/com/siriusxi/ms/store/ps/ProductServiceApplicationTests.java @@ -1,47 +1,86 @@ package com.siriusxi.ms.store.ps; +import com.siriusxi.ms.store.api.core.product.Product; +import com.siriusxi.ms.store.ps.persistence.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; +import static org.springframework.http.HttpStatus.*; import static org.springframework.http.MediaType.APPLICATION_JSON; -@SpringBootTest(webEnvironment = RANDOM_PORT) +@SpringBootTest(webEnvironment= RANDOM_PORT, properties = {"spring.data.mongodb.port: 0"}) class ProductServiceApplicationTests { + private final String BASE_URI = "/products/"; + @Autowired private WebTestClient client; + @Autowired + private ProductRepository repository; + + @BeforeEach + public void setupDb() { + repository.deleteAll(); + } + + @Test public void getProductById() { int productId = 1; - client.get() - .uri("/product/" + productId) - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isOk() - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() + postAndVerifyProduct(productId, OK); + + assertTrue(repository.findByProductId(productId).isPresent()); + + getAndVerifyProduct(productId, OK) .jsonPath("$.productId").isEqualTo(productId); } + @Test + @Disabled + public void duplicateError() { + + int productId = 1; + + postAndVerifyProduct(productId, OK); + + assertTrue(repository.findByProductId(productId).isPresent()); + + postAndVerifyProduct(productId, UNPROCESSABLE_ENTITY) + .jsonPath("$.path").isEqualTo("BASE_RESOURCE_URI") + .jsonPath("$.message").isEqualTo("Duplicate key, Product Id: " + productId); + } + + @Test + public void deleteProduct() { + + int productId = 1; + + postAndVerifyProduct(productId, OK); + assertTrue(repository.findByProductId(productId).isPresent()); + + deleteAndVerifyProduct(productId, OK); + assertFalse(repository.findByProductId(productId).isPresent()); + + deleteAndVerifyProduct(productId, OK); + } + @Test public void getProductInvalidParameterString() { - client.get() - .uri("/product/no-integer") - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isEqualTo(BAD_REQUEST) - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo("/product/no-integer") + getAndVerifyProduct(BASE_URI + "/no-integer", BAD_REQUEST) + .jsonPath("$.path").isEqualTo(BASE_URI + "no-integer") .jsonPath("$.message").isEqualTo("Type mismatch."); } @@ -49,15 +88,8 @@ public void getProductInvalidParameterString() { public void getProductNotFound() { int productIdNotFound = 13; - - client.get() - .uri("/product/" + productIdNotFound) - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isNotFound() - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo("/product/" + productIdNotFound) + getAndVerifyProduct(productIdNotFound, NOT_FOUND) + .jsonPath("$.path").isEqualTo(BASE_URI + productIdNotFound) .jsonPath("$.message").isEqualTo("No product found for productId: " + productIdNotFound); } @@ -66,15 +98,45 @@ public void getProductInvalidParameterNegativeValue() { int productIdInvalid = -1; - client.get() - .uri("/product/" + productIdInvalid) + getAndVerifyProduct(productIdInvalid, UNPROCESSABLE_ENTITY) + .jsonPath("$.path").isEqualTo(BASE_URI + productIdInvalid) + .jsonPath("$.message").isEqualTo("Invalid productId: " + productIdInvalid); + } + + + private WebTestClient.BodyContentSpec getAndVerifyProduct(int productId, HttpStatus expectedStatus) { + return getAndVerifyProduct(BASE_URI + productId, expectedStatus); + } + + private WebTestClient.BodyContentSpec getAndVerifyProduct(String productIdPath, HttpStatus expectedStatus) { + return client.get() + .uri(productIdPath) .accept(APPLICATION_JSON) .exchange() - .expectStatus().isEqualTo(UNPROCESSABLE_ENTITY) + .expectStatus().isEqualTo(expectedStatus) .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo("/product/" + productIdInvalid) - .jsonPath("$.message").isEqualTo("Invalid productId: " + productIdInvalid); + .expectBody(); + } + + private WebTestClient.BodyContentSpec postAndVerifyProduct(int productId, HttpStatus expectedStatus) { + Product product = new Product(productId, "Name " + productId, productId, "SA"); + return client.post() + .uri(BASE_URI) + .body(Mono.just(product), Product.class) + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isEqualTo(expectedStatus) + .expectHeader().contentType(APPLICATION_JSON) + .expectBody(); + } + + private WebTestClient.BodyContentSpec deleteAndVerifyProduct(int productId, HttpStatus expectedStatus) { + return client.delete() + .uri(BASE_URI + productId) + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isEqualTo(expectedStatus) + .expectBody(); } } diff --git a/recommendation-service/pom.xml b/recommendation-service/pom.xml index f9334c4f..49538a63 100644 --- a/recommendation-service/pom.xml +++ b/recommendation-service/pom.xml @@ -16,4 +16,21 @@ 1.0-SNAPSHOT Recommendation Service Spring Boot based project jar + + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + + de.flapdoodle.embed + de.flapdoodle.embed.mongo + test + + + diff --git a/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/RecommendationServiceApplication.java b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/RecommendationServiceApplication.java index 29072e5f..c60fa311 100644 --- a/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/RecommendationServiceApplication.java +++ b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/RecommendationServiceApplication.java @@ -1,15 +1,22 @@ package com.siriusxi.ms.store.rs; +import lombok.extern.log4j.Log4j2; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.ComponentScan; @SpringBootApplication @ComponentScan("com.siriusxi.ms.store") +@Log4j2 public class RecommendationServiceApplication { public static void main(String[] args) { - SpringApplication.run(RecommendationServiceApplication.class, args); + ConfigurableApplicationContext ctx = SpringApplication.run(RecommendationServiceApplication.class, args); + + String mongodDbHost = ctx.getEnvironment().getProperty("spring.data.mongodb.host"); + String mongodDbPort = ctx.getEnvironment().getProperty("spring.data.mongodb.port"); + log.info("Connected to MongoDb: " + mongodDbHost + ":" + mongodDbPort); } } diff --git a/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/controller/RecommendationMapper.java b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/controller/RecommendationMapper.java new file mode 100644 index 00000000..f03814f2 --- /dev/null +++ b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/controller/RecommendationMapper.java @@ -0,0 +1,28 @@ +package com.siriusxi.ms.store.rs.controller; + +import com.siriusxi.ms.store.api.core.recommendation.Recommendation; +import com.siriusxi.ms.store.rs.persistence.RecommendationEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface RecommendationMapper { + + RecommendationMapper INSTANCE = Mappers.getMapper(RecommendationMapper.class); + + @Mapping(target = "rate", source = "entity.rating") + @Mapping(target = "serviceAddress", ignore = true) + Recommendation entityToApi(RecommendationEntity entity); + + @Mapping(target = "rating", source = "api.rate") + @Mapping(target = "id", ignore = true) + @Mapping(target = "version", ignore = true) + RecommendationEntity apiToEntity(Recommendation api); + + List entityListToApiList(List entity); + + List apiListToEntityList(List api); +} diff --git a/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/controller/RecommendationServiceImpl.java b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/controller/RecommendationServiceImpl.java index c3026a44..611517a0 100644 --- a/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/controller/RecommendationServiceImpl.java +++ b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/controller/RecommendationServiceImpl.java @@ -1,44 +1,73 @@ package com.siriusxi.ms.store.rs.controller; +import com.mongodb.DuplicateKeyException; import com.siriusxi.ms.store.api.core.recommendation.Recommendation; import com.siriusxi.ms.store.api.core.recommendation.RecommendationService; +import com.siriusxi.ms.store.rs.persistence.RecommendationEntity; +import com.siriusxi.ms.store.rs.persistence.RecommendationRepository; import com.siriusxi.ms.store.util.exceptions.InvalidInputException; import com.siriusxi.ms.store.util.http.ServiceUtil; import lombok.extern.log4j.Log4j2; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.web.bind.annotation.RestController; import java.util.ArrayList; import java.util.List; +// FIXME to extract service from controller @RestController @Log4j2 public class RecommendationServiceImpl implements RecommendationService { + private final RecommendationRepository repository; + + private final RecommendationMapper mapper; + private final ServiceUtil serviceUtil; @Autowired - public RecommendationServiceImpl(ServiceUtil serviceUtil) { + public RecommendationServiceImpl(RecommendationRepository repository, + RecommendationMapper mapper, + ServiceUtil serviceUtil) { + this.repository = repository; + this.mapper = mapper; this.serviceUtil = serviceUtil; } @Override - public List getRecommendations(int productId) { + public Recommendation createRecommendation(Recommendation body) { + try { + RecommendationEntity entity = mapper.apiToEntity(body); + RecommendationEntity newEntity = repository.save(entity); - if (productId < 1) throw new InvalidInputException("Invalid productId: " + productId); + log.debug("createRecommendation: created a recommendation entity: {}/{}", + body.getProductId(), body.getRecommendationId()); + return mapper.entityToApi(newEntity); - if (productId == 113) { - log.debug("No recommendations found for productId: {}", productId); - return new ArrayList<>(); + } catch (DuplicateKeyException dke) { + throw new InvalidInputException("Duplicate key, Product Id: " + body.getProductId() + ", Recommendation Id:" + body.getRecommendationId()); } + } - List list = new ArrayList<>(); - list.add(new Recommendation(productId, 1, "Author 1", 1, "Content 1", serviceUtil.getServiceAddress())); - list.add(new Recommendation(productId, 2, "Author 2", 2, "Content 2", serviceUtil.getServiceAddress())); - list.add(new Recommendation(productId, 3, "Author 3", 3, "Content 3", serviceUtil.getServiceAddress())); + @Override + public List getRecommendations(int productId) { - log.debug("/recommendation response size: {}", list.size()); + if (productId < 1) throw new InvalidInputException("Invalid productId: " + productId); + + List entityList = repository.findByProductId(productId); + List list = mapper.entityListToApiList(entityList); + list.forEach(e -> e.setServiceAddress(serviceUtil.getServiceAddress())); + + log.debug("getRecommendations: response size: {}", list.size()); return list; } + + @Override + public void deleteRecommendations(int productId) { + log.debug("deleteRecommendations: tries to delete recommendations for the product with " + + "productId: {}", productId); + repository.deleteAll(repository.findByProductId(productId)); + } } diff --git a/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/persistence/RecommendationEntity.java b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/persistence/RecommendationEntity.java new file mode 100644 index 00000000..1128f461 --- /dev/null +++ b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/persistence/RecommendationEntity.java @@ -0,0 +1,40 @@ +package com.siriusxi.ms.store.rs.persistence; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Version; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "recommendations") +@CompoundIndex( + name = "prod-rec-id", + unique = true, + def = "{'productId': 1, 'recommendationId' : 1}") +@Data +@NoArgsConstructor +public class RecommendationEntity { + + @Id + private String id; + + @Version + private Integer version; + + private int productId; + private int recommendationId; + private String author; + private int rating; + private String content; + + public RecommendationEntity( + int productId, int recommendationId, String author, int rating, String content) { + this.productId = productId; + this.recommendationId = recommendationId; + this.author = author; + this.rating = rating; + this.content = content; + } + +} diff --git a/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/persistence/RecommendationRepository.java b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/persistence/RecommendationRepository.java new file mode 100644 index 00000000..3e80bcd2 --- /dev/null +++ b/recommendation-service/src/main/java/com/siriusxi/ms/store/rs/persistence/RecommendationRepository.java @@ -0,0 +1,11 @@ +package com.siriusxi.ms.store.rs.persistence; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RecommendationRepository extends CrudRepository { + List findByProductId(int productId); +} \ No newline at end of file diff --git a/recommendation-service/src/main/resources/application.yaml b/recommendation-service/src/main/resources/application.yaml index d0af2644..f7b79587 100644 --- a/recommendation-service/src/main/resources/application.yaml +++ b/recommendation-service/src/main/resources/application.yaml @@ -1,6 +1,11 @@ spring: application: name: recommendation-service + data: + mongodb: + host: localhost + port: 27017 + database: recommendation-db server: port: 9082 @@ -9,7 +14,10 @@ logging: level: web: DEBUG root: INFO - com.siriusxi.ms:.store: DEBUG + com.siriusxi.ms.store: DEBUG + org: + springframework.data.mongodb.core.MongoTemplate: DEBUG + mongodb: debug management: endpoints: @@ -28,6 +36,9 @@ spring: profiles: docker jmx: enabled: false + data: + mongodb: + host: mongodb server: port: 8080 diff --git a/recommendation-service/src/main/resources/banner.txt b/recommendation-service/src/main/resources/banner.txt new file mode 100644 index 00000000..dc04004f --- /dev/null +++ b/recommendation-service/src/main/resources/banner.txt @@ -0,0 +1,13 @@ + + _____ _ _ __ _______ _____ _ _____ _ ______ + / ____(_) (_) \ \ / /_ _| / ____| (_) / ____| | \ \ \ \ +| (___ _ _ __ _ _ _ ___ \ V / | | ______ | (___ _ __ _ __ _ _ __ __ _ _ _ | (___ | |_ ___ _ __ ___ \ \ \ \ + \___ \| | '__| | | | / __| > < | | |______| \___ \| '_ \| '__| | '_ \ / _` | | | | \___ \| __/ _ \| '__/ _ \ > > > > + ____) | | | | | |_| \__ \/ . \ _| |_ ____) | |_) | | | | | | | (_| | |_| | ____) | || (_) | | | __// / / / +|_____/|_|_| |_|\__,_|___/_/ \_\_____| |_____/| .__/|_| |_|_| |_|\__, |\__, | |_____/ \__\___/|_| \___/_/_/_/ + | | __/ | __/ | + |_| |___/ |___/ + +:: ${application.title} (Microservice) :: v${application.version} :: Built with Spring Boot :: v${spring-boot.version} + + diff --git a/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/MapperTests.java b/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/MapperTests.java new file mode 100644 index 00000000..74942e60 --- /dev/null +++ b/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/MapperTests.java @@ -0,0 +1,73 @@ +package com.siriusxi.ms.store.rs; + +import com.siriusxi.ms.store.api.core.recommendation.Recommendation; +import com.siriusxi.ms.store.rs.controller.RecommendationMapper; +import com.siriusxi.ms.store.rs.persistence.RecommendationEntity; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class MapperTests { + + private final RecommendationMapper mapper = RecommendationMapper.INSTANCE; + + @Test + public void mapperTests() { + + assertNotNull(mapper); + + Recommendation api = new Recommendation(1, 2, "a", 4, "C", "adr"); + + RecommendationEntity entity = mapper.apiToEntity(api); + + assertEquals(api.getProductId(), entity.getProductId()); + assertEquals(api.getRecommendationId(), entity.getRecommendationId()); + assertEquals(api.getAuthor(), entity.getAuthor()); + assertEquals(api.getRate(), entity.getRating()); + assertEquals(api.getContent(), entity.getContent()); + + Recommendation api2 = mapper.entityToApi(entity); + + assertEquals(api.getProductId(), api2.getProductId()); + assertEquals(api.getRecommendationId(), api2.getRecommendationId()); + assertEquals(api.getAuthor(), api2.getAuthor()); + assertEquals(api.getRate(), api2.getRate()); + assertEquals(api.getContent(), api2.getContent()); + assertNull(api2.getServiceAddress()); + } + + @Test + public void mapperListTests() { + + assertNotNull(mapper); + + Recommendation api = new Recommendation(1, 2, "a", 4, "C", "adr"); + List apiList = Collections.singletonList(api); + + List entityList = mapper.apiListToEntityList(apiList); + assertEquals(apiList.size(), entityList.size()); + + RecommendationEntity entity = entityList.get(0); + + assertEquals(api.getProductId(), entity.getProductId()); + assertEquals(api.getRecommendationId(), entity.getRecommendationId()); + assertEquals(api.getAuthor(), entity.getAuthor()); + assertEquals(api.getRate(), entity.getRating()); + assertEquals(api.getContent(), entity.getContent()); + + List api2List = mapper.entityListToApiList(entityList); + assertEquals(apiList.size(), api2List.size()); + + Recommendation api2 = api2List.get(0); + + assertEquals(api.getProductId(), api2.getProductId()); + assertEquals(api.getRecommendationId(), api2.getRecommendationId()); + assertEquals(api.getAuthor(), api2.getAuthor()); + assertEquals(api.getRate(), api2.getRate()); + assertEquals(api.getContent(), api2.getContent()); + assertNull(api2.getServiceAddress()); + } +} diff --git a/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/PersistenceTests.java b/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/PersistenceTests.java new file mode 100644 index 00000000..53123a84 --- /dev/null +++ b/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/PersistenceTests.java @@ -0,0 +1,125 @@ +package com.siriusxi.ms.store.rs; + +import com.mongodb.DuplicateKeyException; +import com.siriusxi.ms.store.rs.persistence.RecommendationEntity; +import com.siriusxi.ms.store.rs.persistence.RecommendationRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest; +import org.springframework.dao.OptimisticLockingFailureException; + +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.*; + +// FIXME to fix all optional class check with isPresent() +@DataMongoTest +public class PersistenceTests { + + @Autowired + private RecommendationRepository repository; + + private RecommendationEntity savedEntity; + + @BeforeEach + public void setupDb() { + repository.deleteAll(); + + RecommendationEntity entity = new RecommendationEntity(1, 2, "a", 3, "c"); + savedEntity = repository.save(entity); + + assertEqualsRecommendation(entity, savedEntity); + } + + + @Test + public void create() { + + RecommendationEntity newEntity = new RecommendationEntity(1, 3, "a", 3, "c"); + repository.save(newEntity); + + RecommendationEntity foundEntity = repository.findById(newEntity.getId()).get(); + assertEqualsRecommendation(newEntity, foundEntity); + + assertEquals(2, repository.count()); + } + + @Test + public void update() { + savedEntity.setAuthor("a2"); + repository.save(savedEntity); + + RecommendationEntity foundEntity = repository.findById(savedEntity.getId()).get(); + assertEquals(1, (long)foundEntity.getVersion()); + assertEquals("a2", foundEntity.getAuthor()); + } + + @Test + public void delete() { + repository.delete(savedEntity); + assertFalse(repository.existsById(savedEntity.getId())); + } + + @Test + public void getByProductId() { + List entityList = repository.findByProductId(savedEntity.getProductId()); + + assertThat(entityList, hasSize(1)); + assertEqualsRecommendation(savedEntity, entityList.get(0)); + } + + //FIXME error which is not thrown + @Test + @Disabled + public void duplicateError() { + + Assertions.assertThrows(DuplicateKeyException.class, + () -> { + RecommendationEntity entity = new RecommendationEntity(1, + 2, "a", 3, "c"); + repository.save(entity); + }); + } + + @Test + public void optimisticLockError() { + + // Store the saved entity in two separate entity objects + RecommendationEntity entity1 = repository.findById(savedEntity.getId()).get(); + RecommendationEntity entity2 = repository.findById(savedEntity.getId()).get(); + + // Update the entity using the first entity object + entity1.setAuthor("a1"); + repository.save(entity1); + + // Update the entity using the second entity object. + // This should fail since the second entity now holds a old version number, i.e. a Optimistic Lock Error + try { + entity2.setAuthor("a2"); + repository.save(entity2); + + fail("Expected an OptimisticLockingFailureException"); + } catch (OptimisticLockingFailureException ignored) {} + + // Get the updated entity from the database and verify its new sate + RecommendationEntity updatedEntity = repository.findById(savedEntity.getId()).get(); + assertEquals(1, (int)updatedEntity.getVersion()); + assertEquals("a1", updatedEntity.getAuthor()); + } + + private void assertEqualsRecommendation(RecommendationEntity expectedEntity, + RecommendationEntity actualEntity) { + assertEquals(expectedEntity.getId(), actualEntity.getId()); + assertEquals(expectedEntity.getVersion(), actualEntity.getVersion()); + assertEquals(expectedEntity.getProductId(), actualEntity.getProductId()); + assertEquals(expectedEntity.getRecommendationId(), actualEntity.getRecommendationId()); + assertEquals(expectedEntity.getAuthor(), actualEntity.getAuthor()); + assertEquals(expectedEntity.getRating(), actualEntity.getRating()); + assertEquals(expectedEntity.getContent(), actualEntity.getContent()); + } +} diff --git a/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/RecommendationServiceApplicationTests.java b/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/RecommendationServiceApplicationTests.java index 16fdd9f3..1a2938fc 100644 --- a/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/RecommendationServiceApplicationTests.java +++ b/recommendation-service/src/test/java/com/siriusxi/ms/store/rs/RecommendationServiceApplicationTests.java @@ -1,77 +1,110 @@ package com.siriusxi.ms.store.rs; +import com.siriusxi.ms.store.api.core.recommendation.Recommendation; +import com.siriusxi.ms.store.rs.persistence.RecommendationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClient.BodyContentSpec; +import reactor.core.publisher.Mono; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; +import static org.springframework.http.HttpStatus.*; import static org.springframework.http.MediaType.APPLICATION_JSON; -@SpringBootTest(webEnvironment = RANDOM_PORT) +@SpringBootTest(webEnvironment = RANDOM_PORT, properties = {"spring.data.mongodb.port: 0"}) class RecommendationServiceApplicationTests { + private final String BASE_URI = "/recommendations"; @Autowired private WebTestClient client; + @Autowired + private RecommendationRepository repository; + + + @BeforeEach + public void setupDb() { + repository.deleteAll(); + } + @Test public void getRecommendationsByProductId() { int productId = 1; - client.get() - .uri("/recommendation?productId=" + productId) - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isOk() - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() + postAndVerifyRecommendation(productId, 1, OK); + postAndVerifyRecommendation(productId, 2, OK); + postAndVerifyRecommendation(productId, 3, OK); + + assertEquals(3, repository.findByProductId(productId).size()); + + getAndVerifyRecommendationsByProductId(productId, OK) .jsonPath("$.length()").isEqualTo(3) - .jsonPath("$[0].productId").isEqualTo(productId); + .jsonPath("$[2].productId").isEqualTo(productId) + .jsonPath("$[2].recommendationId").isEqualTo(3); + } + + @Test + @Disabled + public void duplicateError() { + + int productId = 1; + int recommendationId = 1; + + postAndVerifyRecommendation(productId, recommendationId, OK) + .jsonPath("$.productId").isEqualTo(productId) + .jsonPath("$.recommendationId").isEqualTo(recommendationId); + + assertEquals(1, repository.count()); + + postAndVerifyRecommendation(productId, recommendationId, UNPROCESSABLE_ENTITY) + .jsonPath("$.path").isEqualTo(BASE_URI) + .jsonPath("$.message").isEqualTo("Duplicate key, Product Id: 1, Recommendation Id:1"); + + assertEquals(1, repository.count()); + } + + @Test + public void deleteRecommendations() { + + int productId = 1; + int recommendationId = 1; + + postAndVerifyRecommendation(productId, recommendationId, OK); + assertEquals(1, repository.findByProductId(productId).size()); + + deleteAndVerifyRecommendationsByProductIdIsOk(productId); + assertEquals(0, repository.findByProductId(productId).size()); + + deleteAndVerifyRecommendationsByProductIdIsOk(productId); } @Test public void getRecommendationsMissingParameter() { - client.get() - .uri("/recommendation") - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isEqualTo(BAD_REQUEST) - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo("/recommendation") + getAndVerifyRecommendationsByProductId("", BAD_REQUEST) + .jsonPath("$.path").isEqualTo(BASE_URI) .jsonPath("$.message").isEqualTo("Required int parameter 'productId' is not present"); } @Test public void getRecommendationsInvalidParameter() { - client.get() - .uri("/recommendation?productId=no-integer") - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isEqualTo(BAD_REQUEST) - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo("/recommendation") + getAndVerifyRecommendationsByProductId("?productId=no-integer", BAD_REQUEST) + .jsonPath("$.path").isEqualTo(BASE_URI) .jsonPath("$.message").isEqualTo("Type mismatch."); } @Test public void getRecommendationsNotFound() { - int productIdNotFound = 113; - - client.get() - .uri("/recommendation?productId=" + productIdNotFound) - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isOk() - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() + getAndVerifyRecommendationsByProductId("?productId=113", OK) .jsonPath("$.length()").isEqualTo(0); } @@ -80,15 +113,51 @@ public void getRecommendationsInvalidParameterNegativeValue() { int productIdInvalid = -1; - client.get() - .uri("/recommendation?productId=" + productIdInvalid) + getAndVerifyRecommendationsByProductId("?productId=" + productIdInvalid, UNPROCESSABLE_ENTITY) + .jsonPath("$.path").isEqualTo(BASE_URI) + .jsonPath("$.message").isEqualTo("Invalid productId: " + productIdInvalid); + } + + private BodyContentSpec getAndVerifyRecommendationsByProductId(int productId, HttpStatus expectedStatus) { + return getAndVerifyRecommendationsByProductId("?productId=" + productId, expectedStatus); + } + + private BodyContentSpec getAndVerifyRecommendationsByProductId(String productIdQuery, + HttpStatus expectedStatus) { + return client.get() + .uri(BASE_URI + productIdQuery) .accept(APPLICATION_JSON) .exchange() - .expectStatus().isEqualTo(UNPROCESSABLE_ENTITY) + .expectStatus().isEqualTo(expectedStatus) .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo("/recommendation") - .jsonPath("$.message").isEqualTo("Invalid productId: " + productIdInvalid); + .expectBody(); + } + + private BodyContentSpec postAndVerifyRecommendation(int productId, + int recommendationId, + HttpStatus expectedStatus) { + + Recommendation recommendation = new Recommendation(productId, + recommendationId, "Author " + recommendationId, + recommendationId, "Content " + recommendationId, "SA"); + + return client.post() + .uri(BASE_URI) + .body(Mono.just(recommendation), Recommendation.class) + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isEqualTo(expectedStatus) + .expectHeader().contentType(APPLICATION_JSON) + .expectBody(); + } + + private void deleteAndVerifyRecommendationsByProductIdIsOk(int productId) { + client.delete() + .uri(BASE_URI + "?productId=" + productId) + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isEqualTo(OK) + .expectBody(); } } diff --git a/review-service/pom.xml b/review-service/pom.xml index d16430ab..6aef6ba7 100644 --- a/review-service/pom.xml +++ b/review-service/pom.xml @@ -16,4 +16,28 @@ 1.0-SNAPSHOT Review Service Spring Boot based project jar + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + + mysql + mysql-connector-java + + + + + + com.h2database + h2 + test + + + diff --git a/review-service/src/main/java/com/siriusxi/ms/store/revs/ReviewServiceApplication.java b/review-service/src/main/java/com/siriusxi/ms/store/revs/ReviewServiceApplication.java index 1917ac14..b42009f3 100644 --- a/review-service/src/main/java/com/siriusxi/ms/store/revs/ReviewServiceApplication.java +++ b/review-service/src/main/java/com/siriusxi/ms/store/revs/ReviewServiceApplication.java @@ -1,15 +1,24 @@ package com.siriusxi.ms.store.revs; +import lombok.extern.log4j.Log4j2; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.ComponentScan; +import static org.springframework.boot.SpringApplication.*; + @SpringBootApplication @ComponentScan("com.siriusxi.ms.store") +@Log4j2 public class ReviewServiceApplication { public static void main(String[] args) { - SpringApplication.run(ReviewServiceApplication.class, args); + + ConfigurableApplicationContext ctx = run(ReviewServiceApplication.class, args); + + String mysqlUri = ctx.getEnvironment().getProperty("spring.datasource.url"); + log.info("Connected to MySQL: " + mysqlUri); } } diff --git a/review-service/src/main/java/com/siriusxi/ms/store/revs/controller/ReviewMapper.java b/review-service/src/main/java/com/siriusxi/ms/store/revs/controller/ReviewMapper.java new file mode 100644 index 00000000..9806155b --- /dev/null +++ b/review-service/src/main/java/com/siriusxi/ms/store/revs/controller/ReviewMapper.java @@ -0,0 +1,27 @@ +package com.siriusxi.ms.store.revs.controller; + +import com.siriusxi.ms.store.api.core.review.Review; +import com.siriusxi.ms.store.revs.persistence.ReviewEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +import static org.mapstruct.factory.Mappers.getMapper; + +@Mapper(componentModel = "spring") +public interface ReviewMapper { + + ReviewMapper INSTANCE = getMapper(ReviewMapper.class); + + @Mapping(target = "serviceAddress", ignore = true) + Review entityToApi(ReviewEntity entity); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "version", ignore = true) + ReviewEntity apiToEntity(Review api); + + List entityListToApiList(List entity); + + List apiListToEntityList(List api); +} diff --git a/review-service/src/main/java/com/siriusxi/ms/store/revs/controller/ReviewServiceImpl.java b/review-service/src/main/java/com/siriusxi/ms/store/revs/controller/ReviewServiceImpl.java index 18a4dea4..2ff8580a 100644 --- a/review-service/src/main/java/com/siriusxi/ms/store/revs/controller/ReviewServiceImpl.java +++ b/review-service/src/main/java/com/siriusxi/ms/store/revs/controller/ReviewServiceImpl.java @@ -2,43 +2,70 @@ import com.siriusxi.ms.store.api.core.review.Review; import com.siriusxi.ms.store.api.core.review.ReviewService; +import com.siriusxi.ms.store.revs.persistence.ReviewEntity; +import com.siriusxi.ms.store.revs.persistence.ReviewRepository; import com.siriusxi.ms.store.util.exceptions.InvalidInputException; import com.siriusxi.ms.store.util.http.ServiceUtil; import lombok.extern.log4j.Log4j2; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.web.bind.annotation.RestController; -import java.util.ArrayList; import java.util.List; @RestController @Log4j2 public class ReviewServiceImpl implements ReviewService { - private final ServiceUtil serviceUtil; + private final ReviewRepository repository; + private final ReviewMapper mapper; + private final ServiceUtil serviceUtil; - @Autowired - public ReviewServiceImpl(ServiceUtil serviceUtil) { - this.serviceUtil = serviceUtil; + @Autowired + public ReviewServiceImpl( + ReviewRepository repository, ReviewMapper mapper, ServiceUtil serviceUtil) { + this.repository = repository; + this.mapper = mapper; + this.serviceUtil = serviceUtil; + } + + @Override + public Review createReview(Review body) { + try { + ReviewEntity entity = mapper.apiToEntity(body); + ReviewEntity newEntity = repository.save(entity); + + log.debug( + "createReview: created a review entity: {}/{}", body.getProductId(), body.getReviewId()); + return mapper.entityToApi(newEntity); + + } catch (DataIntegrityViolationException dive) { + throw new InvalidInputException( + "Duplicate key, Product Id: " + + body.getProductId() + + ", Review Id:" + + body.getReviewId()); } + } - @Override - public List getReviews(int productId) { + @Override + public List getReviews(int productId) { - if (productId < 1) throw new InvalidInputException("Invalid productId: " + productId); + if (productId < 1) throw new InvalidInputException("Invalid productId: " + productId); - if (productId == 213) { - log.debug("No reviews found for productId: {}", productId); - return new ArrayList<>(); - } + List entityList = repository.findByProductId(productId); + List list = mapper.entityListToApiList(entityList); + list.forEach(e -> e.setServiceAddress(serviceUtil.getServiceAddress())); - List list = new ArrayList<>(); - list.add(new Review(productId, 1, "Author 1", "Subject 1", "Content 1", serviceUtil.getServiceAddress())); - list.add(new Review(productId, 2, "Author 2", "Subject 2", "Content 2", serviceUtil.getServiceAddress())); - list.add(new Review(productId, 3, "Author 3", "Subject 3", "Content 3", serviceUtil.getServiceAddress())); + log.debug("getReviews: response size: {}", list.size()); - log.debug("/reviews response size: {}", list.size()); + return list; + } - return list; - } + @Override + public void deleteReviews(int productId) { + log.debug( + "deleteReviews: tries to delete reviews for the product with productId: {}", productId); + repository.deleteAll(repository.findByProductId(productId)); + } } diff --git a/review-service/src/main/java/com/siriusxi/ms/store/revs/persistence/ReviewEntity.java b/review-service/src/main/java/com/siriusxi/ms/store/revs/persistence/ReviewEntity.java new file mode 100644 index 00000000..423cab75 --- /dev/null +++ b/review-service/src/main/java/com/siriusxi/ms/store/revs/persistence/ReviewEntity.java @@ -0,0 +1,35 @@ +package com.siriusxi.ms.store.revs.persistence; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@Entity +@Table( + name = "reviews", + indexes = { + @Index(name = "reviews_unique_idx", unique = true, columnList = "productId,reviewId") + }) +@Data +@NoArgsConstructor +public class ReviewEntity { + + @Id @GeneratedValue private int id; + + @Version private int version; + + private int productId; + private int reviewId; + private String author; + private String subject; + private String content; + + public ReviewEntity(int productId, int reviewId, String author, String subject, String content) { + this.productId = productId; + this.reviewId = reviewId; + this.author = author; + this.subject = subject; + this.content = content; + } +} diff --git a/review-service/src/main/java/com/siriusxi/ms/store/revs/persistence/ReviewRepository.java b/review-service/src/main/java/com/siriusxi/ms/store/revs/persistence/ReviewRepository.java new file mode 100644 index 00000000..7ea848dc --- /dev/null +++ b/review-service/src/main/java/com/siriusxi/ms/store/revs/persistence/ReviewRepository.java @@ -0,0 +1,14 @@ +package com.siriusxi.ms.store.revs.persistence; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +public interface ReviewRepository extends CrudRepository { + + @Transactional(readOnly = true) + List findByProductId(int productId); +} diff --git a/review-service/src/main/resources/application.yaml b/review-service/src/main/resources/application.yaml index 21f2c40b..abfcbab0 100644 --- a/review-service/src/main/resources/application.yaml +++ b/review-service/src/main/resources/application.yaml @@ -2,6 +2,17 @@ spring: application: name: review-service + jpa: + hibernate: + # Strongly recommend to set this property to "none" in a production environment! + ddl-auto: update + datasource: + url: "jdbc:mysql://localhost/review-db" + username: user + password: pwd + hikari: + initializationFailTimeout: 60000 + server: port: 9083 @@ -9,7 +20,11 @@ logging: level: web: DEBUG root: INFO - com.siriusxi.ms:.store: DEBUG + com.siriusxi.ms.store: DEBUG + org: + hibernate: + SQL: DEBUG + type.descriptor.sql.BasicBinder: TRACE management: endpoints: @@ -28,6 +43,8 @@ spring: profiles: docker jmx: enabled: false + datasource: + url: "jdbc:mysql://mysql/review-db" server: port: 8080 \ No newline at end of file diff --git a/review-service/src/main/resources/banner.txt b/review-service/src/main/resources/banner.txt new file mode 100644 index 00000000..dc04004f --- /dev/null +++ b/review-service/src/main/resources/banner.txt @@ -0,0 +1,13 @@ + + _____ _ _ __ _______ _____ _ _____ _ ______ + / ____(_) (_) \ \ / /_ _| / ____| (_) / ____| | \ \ \ \ +| (___ _ _ __ _ _ _ ___ \ V / | | ______ | (___ _ __ _ __ _ _ __ __ _ _ _ | (___ | |_ ___ _ __ ___ \ \ \ \ + \___ \| | '__| | | | / __| > < | | |______| \___ \| '_ \| '__| | '_ \ / _` | | | | \___ \| __/ _ \| '__/ _ \ > > > > + ____) | | | | | |_| \__ \/ . \ _| |_ ____) | |_) | | | | | | | (_| | |_| | ____) | || (_) | | | __// / / / +|_____/|_|_| |_|\__,_|___/_/ \_\_____| |_____/| .__/|_| |_|_| |_|\__, |\__, | |_____/ \__\___/|_| \___/_/_/_/ + | | __/ | __/ | + |_| |___/ |___/ + +:: ${application.title} (Microservice) :: v${application.version} :: Built with Spring Boot :: v${spring-boot.version} + + diff --git a/review-service/src/test/java/com/siriusxi/ms/store/revs/MapperTests.java b/review-service/src/test/java/com/siriusxi/ms/store/revs/MapperTests.java new file mode 100644 index 00000000..b85d65b7 --- /dev/null +++ b/review-service/src/test/java/com/siriusxi/ms/store/revs/MapperTests.java @@ -0,0 +1,73 @@ +package com.siriusxi.ms.store.revs; + +import com.siriusxi.ms.store.api.core.review.Review; +import com.siriusxi.ms.store.revs.controller.ReviewMapper; +import com.siriusxi.ms.store.revs.persistence.ReviewEntity; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class MapperTests { + + private final ReviewMapper mapper = ReviewMapper.INSTANCE; + + @Test + public void mapperTests() { + + assertNotNull(mapper); + + Review api = new Review(1, 2, "a", "s", "C", "adr"); + + ReviewEntity entity = mapper.apiToEntity(api); + + assertEquals(api.getProductId(), entity.getProductId()); + assertEquals(api.getReviewId(), entity.getReviewId()); + assertEquals(api.getAuthor(), entity.getAuthor()); + assertEquals(api.getSubject(), entity.getSubject()); + assertEquals(api.getContent(), entity.getContent()); + + Review api2 = mapper.entityToApi(entity); + + assertEquals(api.getProductId(), api2.getProductId()); + assertEquals(api.getReviewId(), api2.getReviewId()); + assertEquals(api.getAuthor(), api2.getAuthor()); + assertEquals(api.getSubject(), api2.getSubject()); + assertEquals(api.getContent(), api2.getContent()); + assertNull(api2.getServiceAddress()); + } + + @Test + public void mapperListTests() { + + assertNotNull(mapper); + + Review api = new Review(1, 2, "a", "s", "C", "adr"); + List apiList = Collections.singletonList(api); + + List entityList = mapper.apiListToEntityList(apiList); + assertEquals(apiList.size(), entityList.size()); + + ReviewEntity entity = entityList.get(0); + + assertEquals(api.getProductId(), entity.getProductId()); + assertEquals(api.getReviewId(), entity.getReviewId()); + assertEquals(api.getAuthor(), entity.getAuthor()); + assertEquals(api.getSubject(), entity.getSubject()); + assertEquals(api.getContent(), entity.getContent()); + + List api2List = mapper.entityListToApiList(entityList); + assertEquals(apiList.size(), api2List.size()); + + Review api2 = api2List.get(0); + + assertEquals(api.getProductId(), api2.getProductId()); + assertEquals(api.getReviewId(), api2.getReviewId()); + assertEquals(api.getAuthor(), api2.getAuthor()); + assertEquals(api.getSubject(), api2.getSubject()); + assertEquals(api.getContent(), api2.getContent()); + assertNull(api2.getServiceAddress()); + } +} diff --git a/review-service/src/test/java/com/siriusxi/ms/store/revs/PersistenceTests.java b/review-service/src/test/java/com/siriusxi/ms/store/revs/PersistenceTests.java new file mode 100644 index 00000000..9851b0a4 --- /dev/null +++ b/review-service/src/test/java/com/siriusxi/ms/store/revs/PersistenceTests.java @@ -0,0 +1,125 @@ +package com.siriusxi.ms.store.revs; + +import com.siriusxi.ms.store.revs.persistence.ReviewEntity; +import com.siriusxi.ms.store.revs.persistence.ReviewRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.fail; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED; + +@DataJpaTest +@Transactional(propagation = NOT_SUPPORTED) +public class PersistenceTests { + + @Autowired private ReviewRepository repository; + + private ReviewEntity savedEntity; + + @BeforeEach + public void setupDb() { + repository.deleteAll(); + + ReviewEntity entity = new ReviewEntity(1, 2, "a", "s", "c"); + savedEntity = repository.save(entity); + + assertEqualsReview(entity, savedEntity); + } + + @Test + public void create() { + + ReviewEntity newEntity = new ReviewEntity(1, 3, "a", "s", "c"); + repository.save(newEntity); + + ReviewEntity foundEntity = repository.findById(newEntity.getId()).get(); + assertEqualsReview(newEntity, foundEntity); + + assertEquals(2, repository.count()); + } + + @Test + public void update() { + savedEntity.setAuthor("a2"); + repository.save(savedEntity); + + ReviewEntity foundEntity = repository.findById(savedEntity.getId()).get(); + assertEquals(1, (long) foundEntity.getVersion()); + assertEquals("a2", foundEntity.getAuthor()); + } + + @Test + public void delete() { + repository.delete(savedEntity); + assertFalse(repository.existsById(savedEntity.getId())); + } + + @Test + public void getByProductId() { + List entityList = repository.findByProductId(savedEntity.getProductId()); + + assertThat(entityList, hasSize(1)); + assertEqualsReview(savedEntity, entityList.get(0)); + } + + @Test + public void duplicateError() { + + Assertions.assertThrows( + DataIntegrityViolationException.class, + () -> { + ReviewEntity entity = new ReviewEntity(1, 2, "a", "s", "c"); + repository.save(entity); + }); + } + + @Test + public void optimisticLockError() { + + // Store the saved entity in two separate entity objects + ReviewEntity entity1 = repository.findById(savedEntity.getId()).get(); + ReviewEntity entity2 = repository.findById(savedEntity.getId()).get(); + + // Update the entity using the first entity object + entity1.setAuthor("a1"); + repository.save(entity1); + + // Update the entity using the second entity object. + // This should fail since the second entity now holds a old version number, i.e. a Optimistic + // Lock Error + try { + entity2.setAuthor("a2"); + repository.save(entity2); + + fail("Expected an OptimisticLockingFailureException"); + } catch (OptimisticLockingFailureException ignored) { + } + + // Get the updated entity from the database and verify its new sate + ReviewEntity updatedEntity = repository.findById(savedEntity.getId()).get(); + assertEquals(1, (int) updatedEntity.getVersion()); + assertEquals("a1", updatedEntity.getAuthor()); + } + + private void assertEqualsReview(ReviewEntity expectedEntity, ReviewEntity actualEntity) { + assertEquals(expectedEntity.getId(), actualEntity.getId()); + assertEquals(expectedEntity.getVersion(), actualEntity.getVersion()); + assertEquals(expectedEntity.getProductId(), actualEntity.getProductId()); + assertEquals(expectedEntity.getReviewId(), actualEntity.getReviewId()); + assertEquals(expectedEntity.getAuthor(), actualEntity.getAuthor()); + assertEquals(expectedEntity.getSubject(), actualEntity.getSubject()); + assertEquals(expectedEntity.getContent(), actualEntity.getContent()); + } +} diff --git a/review-service/src/test/java/com/siriusxi/ms/store/revs/ReviewServiceApplicationTests.java b/review-service/src/test/java/com/siriusxi/ms/store/revs/ReviewServiceApplicationTests.java index 208c19d1..10df8c72 100644 --- a/review-service/src/test/java/com/siriusxi/ms/store/revs/ReviewServiceApplicationTests.java +++ b/review-service/src/test/java/com/siriusxi/ms/store/revs/ReviewServiceApplicationTests.java @@ -1,77 +1,113 @@ package com.siriusxi.ms.store.revs; +import com.siriusxi.ms.store.api.core.review.Review; +import com.siriusxi.ms.store.revs.persistence.ReviewRepository; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; +import static org.springframework.http.HttpStatus.*; import static org.springframework.http.MediaType.APPLICATION_JSON; -@SpringBootTest(webEnvironment = RANDOM_PORT) +@SpringBootTest(webEnvironment=RANDOM_PORT, properties = { + "spring.datasource.url=jdbc:h2:mem:review-db"}) class ReviewServiceApplicationTests { + private final String BASE_URI = "/reviews"; + @Autowired private WebTestClient client; + @Autowired + private ReviewRepository repository; + + + @BeforeEach + public void setupDb() { + repository.deleteAll(); + } + @Test public void getReviewsByProductId() { int productId = 1; - client.get() - .uri("/review?productId=" + productId) - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isOk() - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() + assertEquals(0, repository.findByProductId(productId).size()); + + postAndVerifyReview(productId, 1, OK); + postAndVerifyReview(productId, 2, OK); + postAndVerifyReview(productId, 3, OK); + + assertEquals(3, repository.findByProductId(productId).size()); + + getAndVerifyReviewsByProductId(productId, OK) .jsonPath("$.length()").isEqualTo(3) - .jsonPath("$[0].productId").isEqualTo(productId); + .jsonPath("$[2].productId").isEqualTo(productId) + .jsonPath("$[2].reviewId").isEqualTo(3); + } + + @Test + public void duplicateError() { + + int productId = 1; + int reviewId = 1; + + assertEquals(0, repository.count()); + + postAndVerifyReview(productId, reviewId, OK) + .jsonPath("$.productId").isEqualTo(productId) + .jsonPath("$.reviewId").isEqualTo(reviewId); + + assertEquals(1, repository.count()); + + postAndVerifyReview(productId, reviewId, UNPROCESSABLE_ENTITY) + .jsonPath("$.path").isEqualTo(BASE_URI) + .jsonPath("$.message").isEqualTo("Duplicate key, Product Id: 1, Review Id:1"); + + assertEquals(1, repository.count()); + } + + @Test + public void deleteReviews() { + + int productId = 1; + int recommendationId = 1; + + postAndVerifyReview(productId, recommendationId, OK); + assertEquals(1, repository.findByProductId(productId).size()); + + deleteAndVerifyReviewsByProductId(productId, OK); + assertEquals(0, repository.findByProductId(productId).size()); + + deleteAndVerifyReviewsByProductId(productId, OK); } @Test public void getReviewsMissingParameter() { - client.get() - .uri("/review") - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isEqualTo(BAD_REQUEST) - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo("/review") + getAndVerifyReviewsByProductId("", BAD_REQUEST) + .jsonPath("$.path").isEqualTo(BASE_URI) .jsonPath("$.message").isEqualTo("Required int parameter 'productId' is not present"); } @Test public void getReviewsInvalidParameter() { - client.get() - .uri("/review?productId=no-integer") - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isEqualTo(BAD_REQUEST) - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo("/review") + getAndVerifyReviewsByProductId("?productId=no-integer", BAD_REQUEST) + .jsonPath("$.path").isEqualTo(BASE_URI) .jsonPath("$.message").isEqualTo("Type mismatch."); } @Test public void getReviewsNotFound() { - int productIdNotFound = 213; - - client.get() - .uri("/review?productId=" + productIdNotFound) - .accept(APPLICATION_JSON) - .exchange() - .expectStatus().isOk() - .expectHeader().contentType(APPLICATION_JSON) - .expectBody() + getAndVerifyReviewsByProductId("?productId=213", OK) .jsonPath("$.length()").isEqualTo(0); } @@ -80,16 +116,49 @@ public void getReviewsInvalidParameterNegativeValue() { int productIdInvalid = -1; - client.get() - .uri("/review?productId=" + productIdInvalid) + getAndVerifyReviewsByProductId("?productId=" + productIdInvalid, + UNPROCESSABLE_ENTITY) + .jsonPath("$.path").isEqualTo(BASE_URI) + .jsonPath("$.message").isEqualTo("Invalid productId: " + productIdInvalid); + } + + private WebTestClient.BodyContentSpec getAndVerifyReviewsByProductId(int productId, + HttpStatus expectedStatus) { + return getAndVerifyReviewsByProductId("?productId=" + productId, expectedStatus); + } + + private WebTestClient.BodyContentSpec getAndVerifyReviewsByProductId(String productIdQuery, + HttpStatus expectedStatus) { + return client.get() + .uri(BASE_URI + productIdQuery) .accept(APPLICATION_JSON) .exchange() - .expectStatus().isEqualTo(UNPROCESSABLE_ENTITY) + .expectStatus().isEqualTo(expectedStatus) .expectHeader().contentType(APPLICATION_JSON) - .expectBody() - .jsonPath("$.path").isEqualTo("/review") - .jsonPath("$.message").isEqualTo("Invalid productId: " + productIdInvalid); + .expectBody(); } + private WebTestClient.BodyContentSpec postAndVerifyReview(int productId, + int reviewId, + HttpStatus expectedStatus) { + Review review = new Review(productId, reviewId, "Author " + reviewId, + "Subject " + reviewId, "Content " + reviewId, "SA"); + return client.post() + .uri(BASE_URI) + .body(Mono.just(review), Review.class) + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isEqualTo(expectedStatus) + .expectHeader().contentType(APPLICATION_JSON) + .expectBody(); + } -} + private void deleteAndVerifyReviewsByProductId(int productId, HttpStatus expectedStatus) { + client.delete() + .uri(BASE_URI + "?productId=" + productId) + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isEqualTo(expectedStatus) + .expectBody(); + } +} \ No newline at end of file diff --git a/setup.sh b/setup.sh index 029e8ec4..2bb5da05 100644 --- a/setup.sh +++ b/setup.sh @@ -3,16 +3,16 @@ ## version: v1.0 echo -e "\nInstalling all Springy store core shared modules" echo -e "................................................\n" -echo "1- Installing [build parent] module..." +echo "1- Installing [Parent Build Chassis] module..." ./mvnw --quiet clean install -pl store-build-chassis || exit 126 echo -e "Done successfully.\n" -echo "2- Installing shared [Utilities] module..." +echo "2- Installing shared [Services Utilities] module..." ./mvnw --quiet clean install -pl store-utils || exit 126 echo -e "Done successfully.\n" -echo "3- Installing shared [APIs] module..." +echo "3- Installing shared [Services APIs] module..." ./mvnw --quiet clean install -pl store-api || exit 126 echo -e "Done successfully.\n" -echo "4- Installing [service parent] module..." +echo "4- Installing [Services Parent Chassis] module..." ./mvnw --quiet clean install -pl store-service-chassis || exit 126 echo -e "Done successfully.\n" diff --git a/store-api/pom.xml b/store-api/pom.xml index 9ec2739e..b4e4ef75 100644 --- a/store-api/pom.xml +++ b/store-api/pom.xml @@ -35,16 +35,6 @@ - - org.projectlombok - lombok - true - - - org.springframework.boot - spring-boot-starter-webflux - - io.springfox diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/StoreService.java b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/StoreService.java new file mode 100644 index 00000000..989db656 --- /dev/null +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/StoreService.java @@ -0,0 +1,104 @@ +package com.siriusxi.ms.store.api.composite; + +import com.siriusxi.ms.store.api.composite.dto.ProductAggregate; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import org.springframework.web.bind.annotation.*; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +@Api("REST API for Springy Store products information.") +@RequestMapping("store/api/v1") +public interface StoreService { + + /** + * Sample usage: curl $HOST:$PORT/store/api/v1/products/1 + * + * @param productId is the product that you are looking for. + * @return the product info, if found, else null. + */ + @ApiOperation( + value = "${api.product-composite.get-composite-product.description}", + notes = "${api.product-composite.get-composite-product.notes}") + @ApiResponses( + value = { + @ApiResponse( + code = 400, + message = """ + Bad Request, invalid format of the request. + See response message for more information. + """), + @ApiResponse(code = 404, message = "Not found, the specified id does not exist."), + @ApiResponse( + code = 422, + message = """ + Unprocessable entity, input parameters caused the processing to fails. + See response message for more information. + """) + }) + @GetMapping(value = "products/{productId}", + produces = APPLICATION_JSON_VALUE) + ProductAggregate getProduct(@PathVariable int productId); + + /** + * Sample usage: + * + *

curl -X POST $HOST:$PORT/store/api/v1/products \ + * -H "Content-Type: application/json" --data \ + * '{"productId":123,"name":"product 123", "weight":123}' + * + * @param body of product elements definition. + */ + @ApiOperation( + value = "${api.product-composite.create-composite-product.description}", + notes = "${api.product-composite.create-composite-product.notes}") + @ApiResponses( + value = { + @ApiResponse( + code = 400, + message = """ + Bad Request, invalid format of the request. + See response message for more information. + """), + @ApiResponse( + code = 422, + message = """ + Unprocessable entity, input parameters caused the processing to fail. + See response message for more information. + """) + }) + @PostMapping( + value = "products", + consumes = APPLICATION_JSON_VALUE) + void createProduct(@RequestBody ProductAggregate body); + + /** + * Sample usage: + * + *

curl -X DELETE $HOST:$PORT/store/api/v1/products/1 + * + * @param productId to delete. + */ + @ApiOperation( + value = "${api.product-composite.delete-composite-product.description}", + notes = "${api.product-composite.delete-composite-product.notes}") + @ApiResponses( + value = { + @ApiResponse( + code = 400, + message =""" + Bad Request, invalid format of the request. + See response message for more information. + """), + @ApiResponse( + code = 422, + message =""" + Unprocessable entity, input parameters caused the processing to fail. + See response message for more information. + """) + }) + @DeleteMapping("products/{productId}") + void deleteProduct(@PathVariable int productId); +} diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/ProductAggregate.java b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/ProductAggregate.java similarity index 72% rename from store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/ProductAggregate.java rename to store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/ProductAggregate.java index 7ab13911..f57379ac 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/ProductAggregate.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/ProductAggregate.java @@ -1,11 +1,10 @@ -package com.siriusxi.ms.store.api.composite.product.dto; +package com.siriusxi.ms.store.api.composite.dto; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; import java.util.List; +// TODO convert it to record @Data @NoArgsConstructor(force = true) @AllArgsConstructor diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/RecommendationSummary.java b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/RecommendationSummary.java similarity index 72% rename from store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/RecommendationSummary.java rename to store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/RecommendationSummary.java index 0682286d..674100f1 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/RecommendationSummary.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/RecommendationSummary.java @@ -1,9 +1,10 @@ -package com.siriusxi.ms.store.api.composite.product.dto; +package com.siriusxi.ms.store.api.composite.dto; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +// TODO convert it to record @Data @NoArgsConstructor(force = true) @AllArgsConstructor @@ -11,4 +12,5 @@ public class RecommendationSummary { private final int recommendationId; private final String author; private final int rate; + private final String content; } diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/ReviewSummary.java b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/ReviewSummary.java similarity index 71% rename from store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/ReviewSummary.java rename to store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/ReviewSummary.java index 5285c18f..22220d8e 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/ReviewSummary.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/ReviewSummary.java @@ -1,9 +1,10 @@ -package com.siriusxi.ms.store.api.composite.product.dto; +package com.siriusxi.ms.store.api.composite.dto; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +// TODO convert it to record @Data @NoArgsConstructor(force = true) @AllArgsConstructor @@ -11,4 +12,5 @@ public class ReviewSummary { private final int reviewId; private final String author; private final String subject; + private final String content; } diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/ServiceAddresses.java b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/ServiceAddresses.java similarity index 82% rename from store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/ServiceAddresses.java rename to store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/ServiceAddresses.java index 0cf1d3ab..9563d1b4 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/dto/ServiceAddresses.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/dto/ServiceAddresses.java @@ -1,10 +1,11 @@ -package com.siriusxi.ms.store.api.composite.product.dto; +package com.siriusxi.ms.store.api.composite.dto; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +// TODO convert it to record @Data @NoArgsConstructor(force = true) @AllArgsConstructor diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/ProductCompositeService.java b/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/ProductCompositeService.java deleted file mode 100644 index 9cbe6d5d..00000000 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/composite/product/ProductCompositeService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.siriusxi.ms.store.api.composite.product; - -import com.siriusxi.ms.store.api.composite.product.dto.ProductAggregate; -import io.swagger.annotations.Api; -import io.swagger.annotations.ApiOperation; -import io.swagger.annotations.ApiResponse; -import io.swagger.annotations.ApiResponses; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; - -import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; - -@RequestMapping("v1") -@Api("REST API for composite product information.") -public interface ProductCompositeService { - - /** - * Sample usage: curl $HOST:$PORT/v1/product-composite/1 - * - * @param productId is the product that you are looking for. - * @return the composite product info, if found, else null. - */ - @ApiOperation( - value = "${api.product-composite.get-composite-product.description}", - notes = "${api.product-composite.get-composite-product.notes}") - @ApiResponses(value = { - @ApiResponse(code = 400, message = "Bad Request, invalid format of the request. " + - "See response message for more information."), - @ApiResponse(code = 404, message = "Not found, the specified id does not exist."), - @ApiResponse(code = 422, message = "Unprocessable entity, input parameters caused the " + - "processing to fails. See response message for more information.") - }) - @GetMapping( - value = "/product-composite/{productId}", - produces = APPLICATION_JSON_VALUE) - ProductAggregate getProduct(@PathVariable int productId); -} diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/core/product/Product.java b/store-api/src/main/java/com/siriusxi/ms/store/api/core/product/Product.java index 45553676..3bc9b4a2 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/core/product/Product.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/core/product/Product.java @@ -5,11 +5,11 @@ import lombok.NoArgsConstructor; @Data -@NoArgsConstructor(force = true) +@NoArgsConstructor @AllArgsConstructor public class Product { - private final int productId; - private final String name; - private final int weight; - private final String serviceAddress; + private int productId; + private String name; + private int weight; + private String serviceAddress; } diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/core/product/ProductService.java b/store-api/src/main/java/com/siriusxi/ms/store/api/core/product/ProductService.java index e19b9205..41f64db8 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/core/product/ProductService.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/core/product/ProductService.java @@ -1,18 +1,43 @@ package com.siriusxi.ms.store.api.core.product; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.*; + import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +//@RequestMapping("products") public interface ProductService { - /** - * Sample usage: curl $HOST:$PORT/product/1 - * - * @param productId is the product that you are looking for. - * @return the product, if found, else null. - */ - @GetMapping( - value = "/product/{productId}", - produces = APPLICATION_JSON_VALUE) - Product getProduct(@PathVariable int productId); + + /** + * Sample usage: curl $HOST:$PORT/products/1 + * + * @param productId is the product that you are looking for. + * @return the product, if found, else null. + */ + @GetMapping(value = "products/{productId}", + produces = APPLICATION_JSON_VALUE) + Product getProduct(@PathVariable int productId); + + /** + * Sample usage: + * + *

curl -X POST $HOST:$PORT/products \ -H "Content-Type: application/json" --data \ + * '{"productId":123,"name":"product 123","weight":123}' + * + * @param body product to save. + * @return just created product. + */ + @PostMapping( value = "products", + produces = APPLICATION_JSON_VALUE, + consumes = APPLICATION_JSON_VALUE) + Product createProduct(@RequestBody Product body); + + /** + * Sample usage: + * + *

curl -X DELETE $HOST:$PORT/products/1 + * + * @param productId to be deleted. + */ + @DeleteMapping("products/{productId}") + void deleteProduct(@PathVariable int productId); } diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/core/recommendation/Recommendation.java b/store-api/src/main/java/com/siriusxi/ms/store/api/core/recommendation/Recommendation.java index 3bd41319..5757c25c 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/core/recommendation/Recommendation.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/core/recommendation/Recommendation.java @@ -8,10 +8,10 @@ @NoArgsConstructor(force = true) @AllArgsConstructor public class Recommendation { - private final int productId; - private final int recommendationId; - private final String author; - private final int rate; - private final String content; - private final String serviceAddress; + private int productId; + private int recommendationId; + private String author; + private int rate; + private String content; + private String serviceAddress; } diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/core/recommendation/RecommendationService.java b/store-api/src/main/java/com/siriusxi/ms/store/api/core/recommendation/RecommendationService.java index df7f82ec..273b85e2 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/core/recommendation/RecommendationService.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/core/recommendation/RecommendationService.java @@ -1,25 +1,46 @@ package com.siriusxi.ms.store.api.core.recommendation; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import java.util.List; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +//@RequestMapping("recommendations") public interface RecommendationService { - /** - * Sample usage: curl $HOST:$PORT/recommendation?productId=1 - * - * @param productId that you are looking for its recommendations. - * @return list of recommendations for this product, - * or empty list if there are no recommendations. - */ - @GetMapping( - value = "/recommendation", - produces = APPLICATION_JSON_VALUE) - List getRecommendations( - @RequestParam(value = "productId") - int productId); + /** + * Sample usage: curl $HOST:$PORT/recommendations?productId=1 + * + * @param productId that you are looking for its recommendations. + * + * @return list of product recommendations, + * or empty list if there are no recommendations. + */ + @GetMapping(value = "recommendations",produces = APPLICATION_JSON_VALUE) + List getRecommendations(@RequestParam("productId") int productId); + + /** + * Sample usage: + * + *

curl -X POST $HOST:$PORT/recommendations \ -H "Content-Type: application/json" --data \ + * '{"productId":123,"recommendationId":456,"author":"me","rate":5,"content":"yada, yada, yada"}' + * + * @param body the recommendation to add. + * @return currently created recommendation. + */ + @PostMapping(value = "recommendations", + produces = APPLICATION_JSON_VALUE, + consumes = APPLICATION_JSON_VALUE) + Recommendation createRecommendation(@RequestBody Recommendation body); + + /** + * Sample usage: + * + *

curl -X DELETE $HOST:$PORT/recommendations?productId=1 + * + * @param productId to delete recommendations for. + */ + @DeleteMapping(value = "recommendations") + void deleteRecommendations(@RequestParam("productId") int productId); } diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/core/review/Review.java b/store-api/src/main/java/com/siriusxi/ms/store/api/core/review/Review.java index 8ef717de..f25dd9a6 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/core/review/Review.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/core/review/Review.java @@ -5,13 +5,13 @@ import lombok.NoArgsConstructor; @Data -@NoArgsConstructor(force = true) +@NoArgsConstructor @AllArgsConstructor public class Review { - private final int productId; - private final int reviewId; - private final String author; - private final String subject; - private final String content; - private final String serviceAddress; + private int productId; + private int reviewId; + private String author; + private String subject; + private String content; + private String serviceAddress; } diff --git a/store-api/src/main/java/com/siriusxi/ms/store/api/core/review/ReviewService.java b/store-api/src/main/java/com/siriusxi/ms/store/api/core/review/ReviewService.java index 08c4e69e..8c149ab3 100644 --- a/store-api/src/main/java/com/siriusxi/ms/store/api/core/review/ReviewService.java +++ b/store-api/src/main/java/com/siriusxi/ms/store/api/core/review/ReviewService.java @@ -1,23 +1,49 @@ package com.siriusxi.ms.store.api.core.review; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import java.util.List; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +//@RequestMapping("reviews") public interface ReviewService { /** - * Sample usage: curl $HOST:$PORT/review?productId=1 + * Sample usage: + * + * curl -X POST $HOST:$PORT/reviews \ + * -H "Content-Type: application/json" --data \ + * '{"productId":123,"reviewId":456,"author":"me","subject":"yada, yada, yada", + * "content":"yada, yada, yada"}' + * + * @param body review to be created. + * @return just created review. + */ + @PostMapping(value = "reviews", + produces = APPLICATION_JSON_VALUE, + consumes = APPLICATION_JSON_VALUE) + Review createReview(@RequestBody Review body); + + /** + * Sample usage: curl $HOST:$PORT/reviews?productId=1 * * @param productId that you are looking for its reviews. * @return list of reviews for this product, * or empty list if there are no reviews. */ - @GetMapping( - value = "/review", + @GetMapping(value = "reviews", produces = APPLICATION_JSON_VALUE) - List getReviews(@RequestParam(value = "productId") int productId); + List getReviews(@RequestParam("productId") int productId); + + + /** + * Sample usage: + * + * curl -X DELETE $HOST:$PORT/review?productId=1 + * + * @param productId to delete its reviews. + */ + @DeleteMapping(value = "reviews") + void deleteReviews(@RequestParam("productId") int productId); } diff --git a/store-build-chassis/pom.xml b/store-build-chassis/pom.xml index 67fcbcda..e8d1a2de 100644 --- a/store-build-chassis/pom.xml +++ b/store-build-chassis/pom.xml @@ -21,15 +21,19 @@ 14 UTF-8 UTF-8 - ../config/maven/store.properties + ../config/maven/store.properties + 3.8.1 3.0.0-M4 3.0.0-M4 1.0.0 3.0.0-SNAPSHOT + 1.3.1.Final + 1.18.12 + true @@ -42,6 +46,22 @@ + + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-webflux + + @@ -52,6 +72,18 @@ ${java.version} --enable-preview + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + org.projectlombok + lombok + ${org.lombok.version} + + diff --git a/store-service-chassis/pom.xml b/store-service-chassis/pom.xml index 2546d5d7..59f94b88 100644 --- a/store-service-chassis/pom.xml +++ b/store-service-chassis/pom.xml @@ -42,10 +42,6 @@ spring-boot-starter-actuator - - org.springframework.boot - spring-boot-starter-webflux - @@ -60,6 +56,11 @@ + + org.hamcrest + hamcrest-library + test + io.projectreactor @@ -82,11 +83,13 @@ + - org.projectlombok - lombok - true + org.mapstruct + mapstruct + ${org.mapstruct.version} + @@ -113,6 +116,7 @@ + diff --git a/product-composite-service/Dockerfile b/store-service/Dockerfile similarity index 100% rename from product-composite-service/Dockerfile rename to store-service/Dockerfile diff --git a/product-composite-service/pom.xml b/store-service/pom.xml similarity index 87% rename from product-composite-service/pom.xml rename to store-service/pom.xml index b2e81ff1..eed5f831 100644 --- a/product-composite-service/pom.xml +++ b/store-service/pom.xml @@ -11,10 +11,10 @@ ../store-service-chassis - product-composite-service - Product Composite Service + store-service + Store Service 1.0-SNAPSHOT - Product Composite Service Spring Boot based project + Store Product Composite Service Spring Boot based project jar diff --git a/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/ProductCompositeServiceApplication.java b/store-service/src/main/java/com/siriusxi/ms/store/pcs/StoreServiceApplication.java similarity index 78% rename from product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/ProductCompositeServiceApplication.java rename to store-service/src/main/java/com/siriusxi/ms/store/pcs/StoreServiceApplication.java index 21309298..31a8606a 100644 --- a/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/ProductCompositeServiceApplication.java +++ b/store-service/src/main/java/com/siriusxi/ms/store/pcs/StoreServiceApplication.java @@ -8,9 +8,8 @@ @SpringBootApplication @EnableSwagger2WebFlux // Starting point for initiating SpringFox @ComponentScan("com.siriusxi.ms.store") -public class ProductCompositeServiceApplication { - +public class StoreServiceApplication { public static void main(String[] args) { - SpringApplication.run(ProductCompositeServiceApplication.class, args); + SpringApplication.run(StoreServiceApplication.class, args); } } diff --git a/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/config/ProductCompositeConfiguration.java b/store-service/src/main/java/com/siriusxi/ms/store/pcs/config/StoreConfiguration.java similarity index 92% rename from product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/config/ProductCompositeConfiguration.java rename to store-service/src/main/java/com/siriusxi/ms/store/pcs/config/StoreConfiguration.java index 2d4a6ff3..e9fd3820 100644 --- a/product-composite-service/src/main/java/com/siriusxi/ms/store/pcs/config/ProductCompositeConfiguration.java +++ b/store-service/src/main/java/com/siriusxi/ms/store/pcs/config/StoreConfiguration.java @@ -10,12 +10,12 @@ import springfox.documentation.spring.web.plugins.Docket; import static java.util.Collections.emptyList; -import static org.springframework.web.bind.annotation.RequestMethod.GET; +import static org.springframework.web.bind.annotation.RequestMethod.*; import static springfox.documentation.builders.RequestHandlerSelectors.basePackage; import static springfox.documentation.spi.DocumentationType.SWAGGER_2; @Configuration -public class ProductCompositeConfiguration { +public class StoreConfiguration { @Value("${api.common.version}") String apiVersion; @Value("${api.common.title}") @@ -54,13 +54,15 @@ public Docket apiDocumentation() { Using the apis() and paths() methods, we can specify where SpringFox shall look for API documentation. */ - .apis(basePackage("com.siriusxi.ms.store.pcs")) + .apis(basePackage("com.siriusxi.ms.store")) .paths(PathSelectors.any()) .build() /* Using the globalResponseMessage() method, we ask SpringFox not to add any default HTTP response codes to the API documentation, such as 401 and 403, which we don't currently use. */ + .globalResponseMessage(POST, emptyList()) .globalResponseMessage(GET, emptyList()) + .globalResponseMessage(DELETE, emptyList()) /* The api* variables that are used to configure the Docket bean with general information about the API are initialized from the property file using Spring @Value annotations. */ diff --git a/store-service/src/main/java/com/siriusxi/ms/store/pcs/controller/StoreServiceImpl.java b/store-service/src/main/java/com/siriusxi/ms/store/pcs/controller/StoreServiceImpl.java new file mode 100644 index 00000000..877b1f22 --- /dev/null +++ b/store-service/src/main/java/com/siriusxi/ms/store/pcs/controller/StoreServiceImpl.java @@ -0,0 +1,127 @@ +package com.siriusxi.ms.store.pcs.controller; + +import com.siriusxi.ms.store.api.composite.StoreService; +import com.siriusxi.ms.store.api.composite.dto.ProductAggregate; +import com.siriusxi.ms.store.api.composite.dto.RecommendationSummary; +import com.siriusxi.ms.store.api.composite.dto.ReviewSummary; +import com.siriusxi.ms.store.api.composite.dto.ServiceAddresses; +import com.siriusxi.ms.store.api.core.product.Product; +import com.siriusxi.ms.store.api.core.recommendation.Recommendation; +import com.siriusxi.ms.store.api.core.review.Review; +import com.siriusxi.ms.store.pcs.integration.StoreIntegration; +import com.siriusxi.ms.store.util.exceptions.NotFoundException; +import com.siriusxi.ms.store.util.http.ServiceUtil; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@Log4j2 +public class StoreServiceImpl implements StoreService { + + private final ServiceUtil serviceUtil; + private final StoreIntegration integration; + + @Autowired + public StoreServiceImpl(ServiceUtil serviceUtil, + StoreIntegration integration) { + this.serviceUtil = serviceUtil; + this.integration = integration; + } + + @Override + public void createProduct(ProductAggregate body) { + + try { + + log.debug("createCompositeProduct: creates a new composite entity for productId: {}", + body.getProductId()); + + Product product = new Product(body.getProductId(), body.getName(), body.getWeight(), null); + integration.createProduct(product); + + if (body.getRecommendations() != null) { + body.getRecommendations().forEach(r -> { + Recommendation recommendation = new Recommendation(body.getProductId(), r.getRecommendationId(), r.getAuthor(), r.getRate(), r.getContent(), null); + integration.createRecommendation(recommendation); + }); + } + + if (body.getReviews() != null) { + body.getReviews().forEach(r -> { + Review review = new Review(body.getProductId(), r.getReviewId(), r.getAuthor(), r.getSubject(), r.getContent(), null); + integration.createReview(review); + }); + } + + log.debug("createCompositeProduct: composite entites created for productId: {}", + body.getProductId()); + + } catch (RuntimeException re) { + log.warn("createCompositeProduct failed", re); + throw re; + } + } + + @Override + public ProductAggregate getProduct(int productId) { + log.debug("getCompositeProduct: lookup a product aggregate for productId: {}", productId); + + Product product = integration.getProduct(productId); + if (product == null) throw new NotFoundException("No product found for productId: " + productId); + + List recommendations = integration.getRecommendations(productId); + + List reviews = integration.getReviews(productId); + + log.debug("getCompositeProduct: aggregate entity found for productId: {}", productId); + + return createProductAggregate(product, recommendations, reviews, serviceUtil.getServiceAddress()); + } + + @Override + public void deleteProduct(int productId) { + + log.debug("deleteCompositeProduct: Deletes a product aggregate for productId: {}", + productId); + + integration.deleteProduct(productId); + + integration.deleteRecommendations(productId); + + integration.deleteReviews(productId); + + log.debug("getCompositeProduct: aggregate entities deleted for productId: {}", productId); + } + + private ProductAggregate createProductAggregate(Product product, List recommendations, List reviews, String serviceAddress) { + + // 1. Setup product info + int productId = product.getProductId(); + String name = product.getName(); + int weight = product.getWeight(); + + // 2. Copy summary recommendation info, if available + List recommendationSummaries = (recommendations == null) ? null : + recommendations.stream() + .map(r -> new RecommendationSummary(r.getRecommendationId(), r.getAuthor(), r.getRate(), r.getContent())) + .collect(Collectors.toList()); + + // 3. Copy summary review info, if available + List reviewSummaries = (reviews == null) ? null : + reviews.stream() + .map(r -> new ReviewSummary(r.getReviewId(), r.getAuthor(), r.getSubject(), r.getContent())) + .collect(Collectors.toList()); + + // 4. Create info regarding the involved microservices addresses + String productAddress = product.getServiceAddress(); + String reviewAddress = (reviews != null && reviews.size() > 0) ? reviews.get(0).getServiceAddress() : ""; + String recommendationAddress = (recommendations != null && recommendations.size() > 0) ? recommendations.get(0).getServiceAddress() : ""; + ServiceAddresses serviceAddresses = new ServiceAddresses(serviceAddress, productAddress, reviewAddress, recommendationAddress); + + return new ProductAggregate(productId, name, weight, recommendationSummaries, reviewSummaries, serviceAddresses); + } +} diff --git a/store-service/src/main/java/com/siriusxi/ms/store/pcs/integration/StoreIntegration.java b/store-service/src/main/java/com/siriusxi/ms/store/pcs/integration/StoreIntegration.java new file mode 100644 index 00000000..1da5d2b0 --- /dev/null +++ b/store-service/src/main/java/com/siriusxi/ms/store/pcs/integration/StoreIntegration.java @@ -0,0 +1,234 @@ +package com.siriusxi.ms.store.pcs.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.siriusxi.ms.store.api.core.product.Product; +import com.siriusxi.ms.store.api.core.product.ProductService; +import com.siriusxi.ms.store.api.core.recommendation.Recommendation; +import com.siriusxi.ms.store.api.core.recommendation.RecommendationService; +import com.siriusxi.ms.store.api.core.review.Review; +import com.siriusxi.ms.store.api.core.review.ReviewService; +import com.siriusxi.ms.store.util.exceptions.InvalidInputException; +import com.siriusxi.ms.store.util.exceptions.NotFoundException; +import com.siriusxi.ms.store.util.http.HttpErrorInfo; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static java.lang.String.valueOf; +import static org.springframework.http.HttpMethod.GET; + +@Component +@Log4j2 +public class StoreIntegration + implements + ProductService, + RecommendationService, + ReviewService { + + private final RestTemplate restTemplate; + private final ObjectMapper mapper; + + private final String productServiceUrl; + private final String recommendationServiceUrl; + private final String reviewServiceUrl; + + @Autowired + public StoreIntegration( + RestTemplate restTemplate, + ObjectMapper mapper, + + @Value("${app.product-service.host}") String productServiceHost, + @Value("${app.product-service.port}") int productServicePort, + + @Value("${app.recommendation-service.host}") String recommendationServiceHost, + @Value("${app.recommendation-service.port}") int recommendationServicePort, + + @Value("${app.review-service.host}") String reviewServiceHost, + @Value("${app.review-service.port}") int reviewServicePort + ) { + + this.restTemplate = restTemplate; + this.mapper = mapper; + + var http = "http://"; + + productServiceUrl = http.concat(productServiceHost).concat(":").concat(valueOf(productServicePort)) + .concat("/products/"); + recommendationServiceUrl = http.concat(recommendationServiceHost).concat(":") + .concat(valueOf(recommendationServicePort)).concat("/recommendations"); + reviewServiceUrl = http.concat(reviewServiceHost).concat(":").concat(valueOf(reviewServicePort)) + .concat("/reviews"); + } + + @Override + public Product createProduct(Product body) { + + try { + String url = productServiceUrl; + log.debug("Will post a new product to URL: {}", url); + + Product product = restTemplate.postForObject(url, body, Product.class); + log.debug("Created a product with id: {}", product.getProductId()); + + return product; + + } catch (HttpClientErrorException ex) { + throw handleHttpClientException(ex); + } + } + + @Override + public Product getProduct(int productId) { + + try { + String url = productServiceUrl + "/" + productId; + log.debug("Will call the getProduct API on URL: {}", url); + + Product product = restTemplate.getForObject(url, Product.class); + log.debug("Found a product with id: {}", product.getProductId()); + + return product; + + } catch (HttpClientErrorException ex) { + throw handleHttpClientException(ex); + } + } + + @Override + public void deleteProduct(int productId) { + try { + String url = productServiceUrl + "/" + productId; + log.debug("Will call the deleteProduct API on URL: {}", url); + + restTemplate.delete(url); + + } catch (HttpClientErrorException ex) { + throw handleHttpClientException(ex); + } + } + + @Override + public Recommendation createRecommendation(Recommendation body) { + + try { + String url = recommendationServiceUrl; + log.debug("Will post a new recommendation to URL: {}", url); + + Recommendation recommendation = restTemplate.postForObject(url, body, Recommendation.class); + log.debug("Created a recommendation with id: {}", recommendation.getProductId()); + + return recommendation; + + } catch (HttpClientErrorException ex) { + throw handleHttpClientException(ex); + } + } + + @Override + public List getRecommendations(int productId) { + + try { + String url = recommendationServiceUrl + "?productId=" + productId; + + log.debug("Will call the getRecommendations API on URL: {}", url); + List recommendations = restTemplate.exchange(url, GET, null, new ParameterizedTypeReference>() {}).getBody(); + + log.debug("Found {} recommendations for a product with id: {}", recommendations.size(), productId); + return recommendations; + + } catch (Exception ex) { + log.warn("Got an exception while requesting recommendations, return zero recommendations: {}", ex.getMessage()); + return new ArrayList<>(); + } + } + + @Override + public void deleteRecommendations(int productId) { + try { + String url = recommendationServiceUrl + "?productId=" + productId; + log.debug("Will call the deleteRecommendations API on URL: {}", url); + + restTemplate.delete(url); + + } catch (HttpClientErrorException ex) { + throw handleHttpClientException(ex); + } + } + + @Override + public Review createReview(Review body) { + + try { + String url = reviewServiceUrl; + log.debug("Will post a new review to URL: {}", url); + + Review review = restTemplate.postForObject(url, body, Review.class); + log.debug("Created a review with id: {}", review.getProductId()); + + return review; + + } catch (HttpClientErrorException ex) { + throw handleHttpClientException(ex); + } + } + + @Override + public List getReviews(int productId) { + + try { + String url = reviewServiceUrl + "?productId=" + productId; + + log.debug("Will call the getReviews API on URL: {}", url); + List reviews = restTemplate.exchange(url, GET, null, new ParameterizedTypeReference>() {}).getBody(); + + log.debug("Found {} reviews for a product with id: {}", reviews.size(), productId); + return reviews; + + } catch (Exception ex) { + log.warn("Got an exception while requesting reviews, return zero reviews: {}", ex.getMessage()); + return new ArrayList<>(); + } + } + + @Override + public void deleteReviews(int productId) { + try { + String url = reviewServiceUrl + "?productId=" + productId; + log.debug("Will call the deleteReviews API on URL: {}", url); + + restTemplate.delete(url); + + } catch (HttpClientErrorException ex) { + throw handleHttpClientException(ex); + } + } + + private RuntimeException handleHttpClientException(HttpClientErrorException ex) { + switch (ex.getStatusCode()) { + case NOT_FOUND: + return new NotFoundException(getErrorMessage(ex)); + case UNPROCESSABLE_ENTITY : + return new InvalidInputException(getErrorMessage(ex)); + default: + log.warn("Got a unexpected HTTP error: {}, will rethrow it", ex.getStatusCode()); + log.warn("Error body: {}", ex.getResponseBodyAsString()); + return ex; + } + } + + private String getErrorMessage(HttpClientErrorException ex) { + try { + return mapper.readValue(ex.getResponseBodyAsString(), HttpErrorInfo.class).message(); + } catch (IOException ioException) { + return ex.getMessage(); + } + } +} diff --git a/product-composite-service/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/store-service/src/main/resources/META-INF/additional-spring-configuration-metadata.json similarity index 75% rename from product-composite-service/src/main/resources/META-INF/additional-spring-configuration-metadata.json rename to store-service/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 2c64bfaa..2833250c 100644 --- a/product-composite-service/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/store-service/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -84,6 +84,26 @@ "name": "api.product-composite.get-composite-product.notes", "type": "java.lang.String", "description": "Description for api.product-composite.get-composite-product.notes." + }, + { + "name": "api.product-composite.create-composite-product.description", + "type": "java.lang.String", + "description": "Description for api.product-composite.get-composite-product.create-composite-product.description." + }, + { + "name": "api.product-composite.create-composite-product.notes", + "type": "java.lang.String", + "description": "Description for api.product-composite.get-composite-product.create-composite-product.notes." + }, + { + "name": "api.product-composite.delete-composite-product.description", + "type": "java.lang.String", + "description": "Description for api.product-composite.delete-composite-product.description." + }, + { + "name": "api.product-composite.delete-composite-product.notes", + "type": "java.lang.String", + "description": "Description for api.product-composite.delete-composite-product.notes." } ] } \ No newline at end of file diff --git a/product-composite-service/src/main/resources/application.yaml b/store-service/src/main/resources/application.yaml similarity index 59% rename from product-composite-service/src/main/resources/application.yaml rename to store-service/src/main/resources/application.yaml index 5b41434f..2e7c1672 100644 --- a/product-composite-service/src/main/resources/application.yaml +++ b/store-service/src/main/resources/application.yaml @@ -1,6 +1,6 @@ spring: application: - name: product-search + name: store-service server: port: 9080 @@ -9,7 +9,7 @@ logging: level: web: DEBUG root: INFO - com.siriusxi.ms:.store: DEBUG + com.siriusxi.ms.store: DEBUG management: endpoints: @@ -59,28 +59,33 @@ api: notes: | # Normal response If the requested product id is found the method will return information regarding: - 1. Base product information + 1. Product information 1. Reviews 1. Recommendations - 1. Service Addresses\n(technical information regarding the addresses of the microservices that created the response) + 1. Service Addresses + (technical information regarding the addresses of the microservices that created the response) # Expected partial and error responses - In the following cases, only a partial response be created (used to simplify testing of error conditions) + 1. If no product information is found, a 404 - Not Found error will be returned + 1. In no recommendations or reviews are found for a product, a partial response will be returned - ## Product id 113 - 200 - Ok, but no recommendations will be returned - - ## Product id 213 - 200 - Ok, but no reviews will be returned - - ## Non numerical product id - 400 - A Bad Request error will be returned + create-composite-product: + description: Creates a composite product + notes: | + # Normal response + The composite product information posted to the API will be splitted up and stored as separate product-info, recommendation and review entities. - ## Product id 13 - 404 - A Not Found error will be returned + # Expected error responses + 1. If a product with the same productId as specified in the posted information already exists, + an 422 - Unprocessable Entity error with a "duplicate key" error message will be returned - ## Negative product ids - 422 - An Unprocessable Entity error will be returned + delete-composite-product: + description: Deletes a product composite + notes: | + # Normal response + Entities for product information, recommendations and reviews related to the specificed productId will be deleted. + The implementation of the delete method is idempotent, i.e. it can be called several times with the same response. + This means that a delete request of a non existing product will return 200 Ok. # ----------------------------------------------- # This is a docker specific profile properties diff --git a/store-service/src/main/resources/banner.txt b/store-service/src/main/resources/banner.txt new file mode 100644 index 00000000..dc04004f --- /dev/null +++ b/store-service/src/main/resources/banner.txt @@ -0,0 +1,13 @@ + + _____ _ _ __ _______ _____ _ _____ _ ______ + / ____(_) (_) \ \ / /_ _| / ____| (_) / ____| | \ \ \ \ +| (___ _ _ __ _ _ _ ___ \ V / | | ______ | (___ _ __ _ __ _ _ __ __ _ _ _ | (___ | |_ ___ _ __ ___ \ \ \ \ + \___ \| | '__| | | | / __| > < | | |______| \___ \| '_ \| '__| | '_ \ / _` | | | | \___ \| __/ _ \| '__/ _ \ > > > > + ____) | | | | | |_| \__ \/ . \ _| |_ ____) | |_) | | | | | | | (_| | |_| | ____) | || (_) | | | __// / / / +|_____/|_|_| |_|\__,_|___/_/ \_\_____| |_____/| .__/|_| |_|_| |_|\__, |\__, | |_____/ \__\___/|_| \___/_/_/_/ + | | __/ | __/ | + |_| |___/ |___/ + +:: ${application.title} (Microservice) :: v${application.version} :: Built with Spring Boot :: v${spring-boot.version} + + diff --git a/store-service/src/test/java/com/siriusxi/ms/store/pcs/StoreServiceApplicationTests.java b/store-service/src/test/java/com/siriusxi/ms/store/pcs/StoreServiceApplicationTests.java new file mode 100644 index 00000000..9ce716a2 --- /dev/null +++ b/store-service/src/test/java/com/siriusxi/ms/store/pcs/StoreServiceApplicationTests.java @@ -0,0 +1,146 @@ +package com.siriusxi.ms.store.pcs; + +import com.siriusxi.ms.store.api.composite.dto.ProductAggregate; +import com.siriusxi.ms.store.api.composite.dto.RecommendationSummary; +import com.siriusxi.ms.store.api.composite.dto.ReviewSummary; +import com.siriusxi.ms.store.api.core.product.Product; +import com.siriusxi.ms.store.api.core.recommendation.Recommendation; +import com.siriusxi.ms.store.api.core.review.Review; +import com.siriusxi.ms.store.pcs.integration.StoreIntegration; +import com.siriusxi.ms.store.util.exceptions.InvalidInputException; +import com.siriusxi.ms.store.util.exceptions.NotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClient.BodyContentSpec; +import reactor.core.publisher.Mono; + +import static java.util.Collections.singletonList; +import static org.mockito.Mockito.when; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.MediaType.APPLICATION_JSON; + +@SpringBootTest(webEnvironment = RANDOM_PORT) +class StoreServiceApplicationTests { + + private static final int PRODUCT_ID_OK = 1; + private static final int PRODUCT_ID_NOT_FOUND = 2; + private static final int PRODUCT_ID_INVALID = 3; + + public static final String BASE_URL = "/store/api/v1/products/"; + + @Autowired + private WebTestClient client; + + @MockBean + private StoreIntegration storeIntegration; + + @BeforeEach + void setUp() { + + when(storeIntegration.getProduct(PRODUCT_ID_OK)). + thenReturn(new Product(PRODUCT_ID_OK, "name", 1, "mock-address")); + + when(storeIntegration.getRecommendations(PRODUCT_ID_OK)). + thenReturn(singletonList(new Recommendation(PRODUCT_ID_OK, + 1, "author", + 1, "content", "mock address"))); + + when(storeIntegration.getReviews(PRODUCT_ID_OK)). + thenReturn(singletonList(new Review(PRODUCT_ID_OK, 1, + "author", "subject", "content", + "mock address"))); + + when(storeIntegration.getProduct(PRODUCT_ID_NOT_FOUND)) + .thenThrow(new NotFoundException("NOT FOUND: " + PRODUCT_ID_NOT_FOUND)); + + when(storeIntegration.getProduct(PRODUCT_ID_INVALID)) + .thenThrow(new InvalidInputException("INVALID: " + PRODUCT_ID_INVALID)); + } + + @Test + public void createCompositeProduct1() { + + ProductAggregate compositeProduct = new ProductAggregate(1, "name", 1, + null, null, null); + + postAndVerifyProductIsCreated(compositeProduct); + } + + @Test + public void createCompositeProduct2() { + ProductAggregate compositeProduct = new ProductAggregate(1, "name", 1, + singletonList(new RecommendationSummary(1, "a", 1, "c")), + singletonList(new ReviewSummary(1, "a", "s", "c")), null); + + postAndVerifyProductIsCreated(compositeProduct); + } + + @Test + public void deleteCompositeProduct() { + ProductAggregate compositeProduct = new ProductAggregate(1, "name", 1, + singletonList(new RecommendationSummary(1, "a", 1, "c")), + singletonList(new ReviewSummary(1, "a", "s", "c")), null); + + postAndVerifyProductIsCreated(compositeProduct); + + deleteAndVerifyProductIsDeleted(compositeProduct.getProductId()); + deleteAndVerifyProductIsDeleted(compositeProduct.getProductId()); + } + + @Test + public void getProductById() { + + getAndVerifyProduct(PRODUCT_ID_OK, OK) + .jsonPath("$.productId").isEqualTo(PRODUCT_ID_OK) + .jsonPath("$.recommendations.length()").isEqualTo(1) + .jsonPath("$.reviews.length()").isEqualTo(1); + } + + @Test + public void getProductNotFound() { + + getAndVerifyProduct(PRODUCT_ID_NOT_FOUND, NOT_FOUND) + .jsonPath("$.path").isEqualTo(BASE_URL + PRODUCT_ID_NOT_FOUND) + .jsonPath("$.message").isEqualTo("NOT FOUND: " + PRODUCT_ID_NOT_FOUND); + } + + @Test + public void getProductInvalidInput() { + + getAndVerifyProduct(PRODUCT_ID_INVALID, UNPROCESSABLE_ENTITY) + .jsonPath("$.path").isEqualTo(BASE_URL + PRODUCT_ID_INVALID) + .jsonPath("$.message").isEqualTo("INVALID: " + PRODUCT_ID_INVALID); + } + + private BodyContentSpec getAndVerifyProduct(int productId, HttpStatus expectedStatus) { + return client.get() + .uri(BASE_URL + productId) + .accept(APPLICATION_JSON) + .exchange() + .expectStatus().isEqualTo(expectedStatus) + .expectHeader().contentType(APPLICATION_JSON) + .expectBody(); + } + + private void postAndVerifyProductIsCreated(ProductAggregate compositeProduct) { + client.post() + .uri(BASE_URL) + .body(Mono.just(compositeProduct), ProductAggregate.class) + .exchange() + .expectStatus().isEqualTo(OK); + } + + private void deleteAndVerifyProductIsDeleted(int productId) { + client.delete() + .uri(BASE_URL + productId) + .exchange() + .expectStatus().isEqualTo(OK); + } + +} diff --git a/store-utils/pom.xml b/store-utils/pom.xml index 72dcbdac..430f4614 100644 --- a/store-utils/pom.xml +++ b/store-utils/pom.xml @@ -4,12 +4,6 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - store-utils - 1.0-SNAPSHOT - Springy Store Utils - Project that define all Springy Store shared functionality - jar - com.siriusxi.ms.store store-build-chassis @@ -17,33 +11,10 @@ ../store-build-chassis - - - 2.3.0.M4 - - - - - - org.springframework.boot - spring-boot-dependencies - ${spring.boot.dependencies.version} - import - pom - - - - - - - org.springframework.boot - spring-boot-starter-webflux - + store-utils + 1.0-SNAPSHOT + Springy Store Utils + Project that define all Springy Store shared functionality + jar - - org.projectlombok - lombok - true - - \ No newline at end of file diff --git a/test-em-all.sh b/test-em-all.sh index 5b003aa2..d064668f 100644 --- a/test-em-all.sh +++ b/test-em-all.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash ## Author: Mohamed Taman -## version: v1.0 +## version: v3.0 ### Sample usage: +# # for local run # HOST=localhost PORT=9080 ./test-em-all.bash # with docker compose @@ -12,6 +13,7 @@ echo -e "Starting [Springy Store] full functionality testing....\n" : ${HOST=localhost} : ${PORT=8080} +BASE_URL="/store/api/v1/products" function assertCurl() { local expectedHttpCode=$1 @@ -80,6 +82,45 @@ function waitForService() { done } +function createProduct() { + local productId=$1 + local composite=$2 + + assertCurl 200 "curl -X DELETE http://${HOST}:${PORT}${BASE_URL}/${productId} -s" + curl -X POST http://${HOST}:${PORT}${BASE_URL} -H "Content-Type: application/json" --data "$composite" +} + +function setupTestData() { + + body=\ +'{"productId":1,"name":"product 1","weight":1, "recommendations":[ + {"recommendationId":1,"author":"author 1","rate":1,"content":"content 1"}, + {"recommendationId":2,"author":"author 2","rate":2,"content":"content 2"}, + {"recommendationId":3,"author":"author 3","rate":3,"content":"content 3"} + ], "reviews":[ + {"reviewId":1,"author":"author 1","subject":"subject 1","content":"content 1"}, + {"reviewId":2,"author":"author 2","subject":"subject 2","content":"content 2"}, + {"reviewId":3,"author":"author 3","subject":"subject 3","content":"content 3"} + ]}' + createProduct 1 "$body" + + body=\ +'{"productId":113,"name":"product 113","weight":113, "reviews":[ + {"reviewId":1,"author":"author 1","subject":"subject 1","content":"content 1"}, + {"reviewId":2,"author":"author 2","subject":"subject 2","content":"content 2"}, + {"reviewId":3,"author":"author 3","subject":"subject 3","content":"content 3"} +]}' + createProduct 113 "$body" + + body=\ +'{"productId":213,"name":"product 213","weight":213, "recommendations":[ + {"recommendationId":1,"author":"author 1","rate":1,"content":"content 1"}, + {"recommendationId":2,"author":"author 2","rate":2,"content":"content 2"}, + {"recommendationId":3,"author":"author 3","rate":3,"content":"content 3"} +]}' + createProduct 213 "$body" +} + set -e echo "Start:" `date` @@ -96,35 +137,37 @@ then docker-compose -p ssm up -d fi -waitForService http://${HOST}:${PORT}/v1/product-composite/1 +waitForService curl -X DELETE http://${HOST}:${PORT}${BASE_URL}/13 + +setupTestData # Verify that a normal request works, expect three recommendations and three reviews -assertCurl 200 "curl http://$HOST:$PORT/v1/product-composite/1 -s" +assertCurl 200 "curl http://$HOST:$PORT${BASE_URL}/1 -s" assertEqual 1 $(echo ${RESPONSE} | jq .productId) assertEqual 3 $(echo ${RESPONSE} | jq ".recommendations | length") assertEqual 3 $(echo ${RESPONSE} | jq ".reviews | length") # Verify that a 404 (Not Found) error is returned for a non existing productId (13) -assertCurl 404 "curl http://$HOST:$PORT/v1/product-composite/13 -s" +assertCurl 404 "curl http://$HOST:$PORT${BASE_URL}/13 -s" # Verify that no recommendations are returned for productId 113 -assertCurl 200 "curl http://$HOST:$PORT/v1/product-composite/113 -s" +assertCurl 200 "curl http://$HOST:$PORT${BASE_URL}/113 -s" assertEqual 113 $(echo ${RESPONSE} | jq .productId) assertEqual 0 $(echo ${RESPONSE} | jq ".recommendations | length") assertEqual 3 $(echo ${RESPONSE} | jq ".reviews | length") # Verify that no reviews are returned for productId 213 -assertCurl 200 "curl http://$HOST:$PORT/v1/product-composite/213 -s" +assertCurl 200 "curl http://$HOST:$PORT${BASE_URL}/213 -s" assertEqual 213 $(echo ${RESPONSE} | jq .productId) assertEqual 3 $(echo ${RESPONSE} | jq ".recommendations | length") assertEqual 0 $(echo ${RESPONSE} | jq ".reviews | length") # Verify that a 422 (Unprocessable Entity) error is returned for a productId that is out of range (-1) -assertCurl 422 "curl http://$HOST:$PORT/v1/product-composite/-1 -s" +assertCurl 422 "curl http://$HOST:$PORT${BASE_URL}/-1 -s" assertEqual "\"Invalid productId: -1\"" "$(echo ${RESPONSE} | jq .message)" # Verify that a 400 (Bad Request) error error is returned for a productId that is not a number, i.e. invalid format -assertCurl 400 "curl http://$HOST:$PORT/v1/product-composite/invalidProductId -s" +assertCurl 400 "curl http://$HOST:$PORT${BASE_URL}/invalidProductId -s" assertEqual "\"Type mismatch.\"" "$(echo ${RESPONSE} | jq .message)" if [[ $@ == *"stop"* ]]