├── .github └── workflows │ ├── api-gateway.yml │ ├── bookstore-webapp.yml │ ├── catalog-service.yml │ ├── notification-service.yml │ └── order-service.yml ├── .gitignore ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── .sdkmanrc ├── LICENSE ├── README.md ├── Taskfile.yml ├── api-gateway ├── .gitignore ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ └── maven-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── sivalabs │ │ │ └── bookstore │ │ │ └── gateway │ │ │ ├── ApiGatewayApplication.java │ │ │ └── SwaggerConfig.java │ └── resources │ │ ├── application.properties.bak │ │ └── application.yml │ └── test │ └── java │ └── com │ └── sivalabs │ └── bookstore │ └── gateway │ └── ApiGatewayApplicationTests.java ├── bookstore-webapp ├── .gitignore ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ └── maven-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ └── main │ ├── java │ └── com │ │ └── sivalabs │ │ └── bookstore │ │ └── webapp │ │ ├── ApplicationProperties.java │ │ ├── BookstoreWebappApplication.java │ │ ├── clients │ │ ├── ClientsConfig.java │ │ ├── catalog │ │ │ ├── CatalogServiceClient.java │ │ │ ├── PagedResult.java │ │ │ └── Product.java │ │ └── orders │ │ │ ├── Address.java │ │ │ ├── CreateOrderRequest.java │ │ │ ├── Customer.java │ │ │ ├── OrderConfirmationDTO.java │ │ │ ├── OrderDTO.java │ │ │ ├── OrderItem.java │ │ │ ├── OrderServiceClient.java │ │ │ ├── OrderStatus.java │ │ │ └── OrderSummary.java │ │ ├── config │ │ └── SecurityConfig.java │ │ ├── services │ │ └── SecurityHelper.java │ │ └── web │ │ └── controllers │ │ ├── OrderController.java │ │ └── ProductController.java │ └── resources │ ├── application.properties │ ├── static │ ├── css │ │ └── styles.css │ ├── images │ │ └── books.png │ └── js │ │ ├── cart.js │ │ ├── cartStore.js │ │ ├── orderDetails.js │ │ ├── orders.js │ │ └── products.js │ └── templates │ ├── cart.html │ ├── fragments │ └── pagination.html │ ├── layout.html │ ├── order_details.html │ ├── orders.html │ └── products.html ├── catalog-service ├── .gitignore ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ └── maven-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── sivalabs │ │ │ └── bookstore │ │ │ └── catalog │ │ │ ├── ApplicationProperties.java │ │ │ ├── CatalogServiceApplication.java │ │ │ ├── config │ │ │ ├── OpenAPI3Configuration.java │ │ │ └── WebMvcConfig.java │ │ │ ├── domain │ │ │ ├── PagedResult.java │ │ │ ├── Product.java │ │ │ ├── ProductEntity.java │ │ │ ├── ProductMapper.java │ │ │ ├── ProductNotFoundException.java │ │ │ ├── ProductRepository.java │ │ │ └── ProductService.java │ │ │ └── web │ │ │ ├── controllers │ │ │ └── ProductController.java │ │ │ └── exception │ │ │ └── GlobalExceptionHandler.java │ └── resources │ │ ├── application.properties │ │ └── db │ │ └── migration │ │ ├── V1__create_products_table.sql │ │ └── V2__add_books_data.sql │ └── test │ ├── java │ └── com │ │ └── sivalabs │ │ └── bookstore │ │ └── catalog │ │ ├── AbstractIT.java │ │ ├── CatalogServiceApplicationTests.java │ │ ├── ContainersConfig.java │ │ ├── TestCatalogServiceApplication.java │ │ ├── domain │ │ └── ProductRepositoryTest.java │ │ └── web │ │ └── controllers │ │ └── ProductControllerTest.java │ └── resources │ └── test-data.sql ├── deployment └── docker-compose │ ├── apps.yml │ ├── infra.yml │ ├── monitoring.yml │ ├── prometheus │ └── prometheus.yml │ ├── promtail │ └── promtail-docker-config.yml │ ├── realm-config │ └── bookstore-realm.json │ └── tempo │ └── tempo.yml ├── docs ├── bookstore-spring-microservices.png ├── spring-microservices-course.slides.pdf └── youtube-thumbnail.png ├── mvnw ├── mvnw.cmd ├── notification-service ├── .gitignore ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ └── maven-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── sivalabs │ │ │ └── bookstore │ │ │ └── notifications │ │ │ ├── ApplicationProperties.java │ │ │ ├── NotificationServiceApplication.java │ │ │ ├── config │ │ │ └── RabbitMQConfig.java │ │ │ ├── domain │ │ │ ├── NotificationService.java │ │ │ ├── OrderEventEntity.java │ │ │ ├── OrderEventRepository.java │ │ │ └── models │ │ │ │ ├── Address.java │ │ │ │ ├── Customer.java │ │ │ │ ├── OrderCancelledEvent.java │ │ │ │ ├── OrderCreatedEvent.java │ │ │ │ ├── OrderDeliveredEvent.java │ │ │ │ ├── OrderErrorEvent.java │ │ │ │ └── OrderItem.java │ │ │ └── events │ │ │ └── OrderEventHandler.java │ └── resources │ │ ├── application.properties │ │ └── db │ │ └── migration │ │ └── V1__create_order_events_table.sql │ └── test │ └── java │ └── com │ └── sivalabs │ └── bookstore │ └── notifications │ ├── AbstractIT.java │ ├── ContainersConfig.java │ ├── NotificationServiceApplicationTests.java │ ├── TestNotificationServiceApplication.java │ └── events │ └── OrderEventHandlerTests.java ├── order-service ├── .gitignore ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ └── maven-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── sivalabs │ │ │ └── bookstore │ │ │ └── orders │ │ │ ├── ApplicationProperties.java │ │ │ ├── OrderServiceApplication.java │ │ │ ├── clients │ │ │ └── catalog │ │ │ │ ├── CatalogServiceClientConfig.java │ │ │ │ ├── Product.java │ │ │ │ └── ProductServiceClient.java │ │ │ ├── config │ │ │ ├── OpenAPI3Configuration.java │ │ │ ├── RabbitMQConfig.java │ │ │ ├── SchedulerConfig.java │ │ │ ├── SecurityConfig.java │ │ │ └── WebMvcConfig.java │ │ │ ├── domain │ │ │ ├── InvalidOrderException.java │ │ │ ├── OrderEntity.java │ │ │ ├── OrderEventEntity.java │ │ │ ├── OrderEventMapper.java │ │ │ ├── OrderEventPublisher.java │ │ │ ├── OrderEventRepository.java │ │ │ ├── OrderEventService.java │ │ │ ├── OrderItemEntity.java │ │ │ ├── OrderMapper.java │ │ │ ├── OrderNotFoundException.java │ │ │ ├── OrderRepository.java │ │ │ ├── OrderService.java │ │ │ ├── OrderValidator.java │ │ │ ├── SecurityService.java │ │ │ └── models │ │ │ │ ├── Address.java │ │ │ │ ├── CreateOrderRequest.java │ │ │ │ ├── CreateOrderResponse.java │ │ │ │ ├── Customer.java │ │ │ │ ├── OrderCancelledEvent.java │ │ │ │ ├── OrderCreatedEvent.java │ │ │ │ ├── OrderDTO.java │ │ │ │ ├── OrderDeliveredEvent.java │ │ │ │ ├── OrderErrorEvent.java │ │ │ │ ├── OrderEventType.java │ │ │ │ ├── OrderItem.java │ │ │ │ ├── OrderStatus.java │ │ │ │ └── OrderSummary.java │ │ │ ├── jobs │ │ │ ├── OrderEventsPublishingJob.java │ │ │ └── OrderProcessingJob.java │ │ │ └── web │ │ │ ├── controllers │ │ │ └── OrderController.java │ │ │ └── exception │ │ │ └── GlobalExceptionHandler.java │ └── resources │ │ ├── application.properties │ │ └── db │ │ └── migration │ │ ├── V1__create_order_tables.sql │ │ ├── V2__create_order_events_table.sql │ │ └── V3__add_shedlock_table.sql │ └── test │ ├── java │ └── com │ │ └── sivalabs │ │ └── bookstore │ │ └── orders │ │ ├── AbstractIT.java │ │ ├── ContainersConfig.java │ │ ├── MockOAuth2UserContextFactory.java │ │ ├── OrderServiceApplicationTests.java │ │ ├── TestOrderServiceApplication.java │ │ ├── WithMockOAuth2User.java │ │ ├── testdata │ │ └── TestDataFactory.java │ │ └── web │ │ └── controllers │ │ ├── GetOrdersTests.java │ │ ├── OrderControllerTests.java │ │ └── OrderControllerUnitTests.java │ └── resources │ ├── test-bookstore-realm.json │ └── test-orders.sql ├── pom.xml └── renovate.json /.github/workflows/api-gateway.yml: -------------------------------------------------------------------------------- 1 | name: API Gateway 2 | 3 | on: 4 | push: 5 | paths: 6 | - api-gateway/** 7 | branches: 8 | - 'main' 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | env: 17 | working-directory: ./api-gateway 18 | DOCKER_IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/bookstore-api-gateway 19 | defaults: 20 | run: 21 | working-directory: ${{ env.working-directory }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Setup Java 21 26 | uses: actions/setup-java@v4 27 | with: 28 | java-version: '21' 29 | distribution: 'temurin' 30 | cache: 'maven' 31 | 32 | - name: Make Maven wrapper executable 33 | run: chmod +x mvnw 34 | 35 | - name: Build with Maven 36 | run: ./mvnw -ntp verify 37 | 38 | - name: Login to Docker Hub 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKERHUB_USERNAME }} 42 | password: ${{ secrets.DOCKERHUB_TOKEN }} 43 | 44 | - name: Build and Publish Docker Image 45 | run: | 46 | ./mvnw spring-boot:build-image -DskipTests 47 | echo "Pushing the image $DOCKER_IMAGE_NAME to Docker Hub..." 48 | docker push $DOCKER_IMAGE_NAME 49 | -------------------------------------------------------------------------------- /.github/workflows/bookstore-webapp.yml: -------------------------------------------------------------------------------- 1 | name: BookStore Webapp 2 | 3 | on: 4 | push: 5 | paths: 6 | - bookstore-webapp/** 7 | branches: 8 | - 'main' 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | env: 17 | working-directory: ./bookstore-webapp 18 | DOCKER_IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/bookstore-webapp 19 | defaults: 20 | run: 21 | working-directory: ${{ env.working-directory }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Setup Java 21 26 | uses: actions/setup-java@v4 27 | with: 28 | java-version: '21' 29 | distribution: 'temurin' 30 | cache: 'maven' 31 | 32 | - name: Make Maven wrapper executable 33 | run: chmod +x mvnw 34 | 35 | - name: Build with Maven 36 | run: ./mvnw -ntp verify 37 | 38 | - name: Login to Docker Hub 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKERHUB_USERNAME }} 42 | password: ${{ secrets.DOCKERHUB_TOKEN }} 43 | 44 | - name: Build and Publish Docker Image 45 | run: | 46 | ./mvnw spring-boot:build-image -DskipTests 47 | echo "Pushing the image $DOCKER_IMAGE_NAME to Docker Hub..." 48 | docker push $DOCKER_IMAGE_NAME 49 | -------------------------------------------------------------------------------- /.github/workflows/catalog-service.yml: -------------------------------------------------------------------------------- 1 | name: Catalog Service 2 | 3 | on: 4 | push: 5 | paths: 6 | - catalog-service/** 7 | branches: 8 | - 'main' 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | env: 17 | working-directory: ./catalog-service 18 | DOCKER_IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/bookstore-catalog-service 19 | defaults: 20 | run: 21 | working-directory: ${{ env.working-directory }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Setup Java 21 26 | uses: actions/setup-java@v4 27 | with: 28 | java-version: '21' 29 | distribution: 'temurin' 30 | cache: 'maven' 31 | 32 | - name: Make Maven wrapper executable 33 | run: chmod +x mvnw 34 | 35 | - name: Build with Maven 36 | run: ./mvnw -ntp verify 37 | 38 | - name: Login to Docker Hub 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKERHUB_USERNAME }} 42 | password: ${{ secrets.DOCKERHUB_TOKEN }} 43 | 44 | - name: Build and Publish Docker Image 45 | run: | 46 | ./mvnw spring-boot:build-image -DskipTests 47 | echo "Pushing the image $DOCKER_IMAGE_NAME to Docker Hub..." 48 | docker push $DOCKER_IMAGE_NAME 49 | -------------------------------------------------------------------------------- /.github/workflows/notification-service.yml: -------------------------------------------------------------------------------- 1 | name: Notification Service 2 | 3 | on: 4 | push: 5 | paths: 6 | - notification-service/** 7 | branches: 8 | - 'main' 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | env: 17 | working-directory: ./notification-service 18 | DOCKER_IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/bookstore-notification-service 19 | defaults: 20 | run: 21 | working-directory: ${{ env.working-directory }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Setup Java 21 26 | uses: actions/setup-java@v4 27 | with: 28 | java-version: '21' 29 | distribution: 'temurin' 30 | cache: 'maven' 31 | 32 | - name: Make Maven wrapper executable 33 | run: chmod +x mvnw 34 | 35 | - name: Build with Maven 36 | run: ./mvnw -ntp verify 37 | 38 | - name: Login to Docker Hub 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKERHUB_USERNAME }} 42 | password: ${{ secrets.DOCKERHUB_TOKEN }} 43 | 44 | - name: Build and Publish Docker Image 45 | run: | 46 | ./mvnw spring-boot:build-image -DskipTests 47 | echo "Pushing the image $DOCKER_IMAGE_NAME to Docker Hub..." 48 | docker push $DOCKER_IMAGE_NAME 49 | -------------------------------------------------------------------------------- /.github/workflows/order-service.yml: -------------------------------------------------------------------------------- 1 | name: Order Service 2 | 3 | on: 4 | push: 5 | paths: 6 | - order-service/** 7 | branches: 8 | - 'main' 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | name: Build 15 | runs-on: ubuntu-latest 16 | env: 17 | working-directory: ./order-service 18 | DOCKER_IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/bookstore-order-service 19 | defaults: 20 | run: 21 | working-directory: ${{ env.working-directory }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Setup Java 21 26 | uses: actions/setup-java@v4 27 | with: 28 | java-version: '21' 29 | distribution: 'temurin' 30 | cache: 'maven' 31 | 32 | - name: Make Maven wrapper executable 33 | run: chmod +x mvnw 34 | 35 | - name: Build with Maven 36 | run: ./mvnw -ntp verify 37 | 38 | - name: Login to Docker Hub 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKERHUB_USERNAME }} 42 | password: ${{ secrets.DOCKERHUB_TOKEN }} 43 | 44 | - name: Build and Publish Docker Image 45 | run: | 46 | ./mvnw spring-boot:build-image -DskipTests 47 | echo "Pushing the image $DOCKER_IMAGE_NAME to Docker Hub..." 48 | docker push $DOCKER_IMAGE_NAME 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /.sdkmanrc: -------------------------------------------------------------------------------- 1 | java=21.0.1-tem 2 | maven=3.9.6 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spring Boot Microservices Course 2 | This repository contains the source code for the [Spring Boot Microservices Course](https://www.youtube.com/playlist?list=PLuNxlOYbv61g_ytin-wgkecfWDKVCEDmB). 3 | 4 | ![Spring Boot Microservices course](docs/youtube-thumbnail.png) 5 | 6 | We will build a BookStore application using Spring Boot, Spring Cloud, and Docker. 7 | 8 | ![BookStore Microservices Architecture](docs/bookstore-spring-microservices.png) 9 | 10 | ## Modules 11 | * **catalog-service**: 12 | This services provides REST API for managing catalog of products(books). 13 | 14 | **TechStack:** Spring Boot, Spring Data JPA, PostgreSQL 15 | 16 | * **order-service**: 17 | This service provides the REST API for managing orders and publishes order events to the message broker. 18 | 19 | **TechStack:** Spring Boot, Spring Security OAuth2, Keycloak, Spring Data JPA, PostgreSQL, RabbitMQ 20 | 21 | * **notification-service**: 22 | This service listens to the order events and sends notifications to the users. 23 | 24 | **TechStack:** Spring Boot, RabbitMQ 25 | 26 | * **api-gateway**: 27 | This service is an API Gateway to the internal backend services (catalog-service, order-service). 28 | 29 | **TechStack:** Spring Boot, Spring Cloud Gateway 30 | 31 | * **bookstore-webapp**: 32 | This is the customer facing web application where customers can browse the catalog, place orders, and view their order details. 33 | 34 | **TechStack:** Spring Boot, Spring Security OAuth2, Keycloak, Thymeleaf, Alpine.js, Bootstrap 35 | 36 | ## Learning Objectives 37 | * Building Spring Boot REST APIs 38 | * Database Persistence using Spring Data JPA, Postgres, Flyway 39 | * Event Driven Async Communication using RabbitMQ 40 | * Implementing OAuth2-based Security using Spring Security and Keycloak 41 | * Implementing API Gateway using Spring Cloud Gateway 42 | * Implementing Resiliency using Resilience4j 43 | * Job Scheduling with ShedLock-based distributed Locking 44 | * Using RestClient, Declarative HTTP Interfaces to invoke other APIs 45 | * Creating Aggregated Swagger Documentation at API Gateway 46 | * Local Development Setup using Docker, Docker Compose and Testcontainers 47 | * Testing using JUnit 5, RestAssured, Testcontainers, Awaitility, WireMock 48 | * Building Web Application using Thymeleaf, Alpine.js, Bootstrap 49 | * Monitoring & Observability using Grafana, Prometheus, Loki, Tempo 50 | 51 | ## Local Development Setup 52 | * Install Java 21. Recommend using [SDKMAN](https://sdkman.io/) for [managing Java versions](https://youtu.be/ZywEiw3EO8A). 53 | * Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) 54 | * Install [IntelliJ IDEA](https://www.jetbrains.com/idea) or any of your favorite IDE 55 | * Install [Postman](https://www.postman.com/) or any REST Client 56 | 57 | ## Other Learning Resources 58 | * [SivaLabs Blog](https://sivalabs.in) 59 | * [Spring Boot Tutorials](https://www.sivalabs.in/spring-boot-tutorials/) 60 | * [Kubernetes Tutorials](https://www.sivalabs.in/getting-started-with-kubernetes/) 61 | * [Spring Security OAuth 2.0 Tutorials](https://www.sivalabs.in/spring-security-oauth2-tutorial-introduction/) 62 | * [A Pragmatic Approach to Software Design](https://www.sivalabs.in/tomato-architecture-pragmatic-approach-to-software-design/) 63 | * [SivaLabs YouTube Channel](https://www.youtube.com/c/SivaLabs) 64 | * [Spring Boot Tips Series](https://www.youtube.com/playlist?list=PLuNxlOYbv61jFFX2ARQKnBgkMF6DvEEic) 65 | * [Spring Boot + Kubernetes Series](https://www.youtube.com/playlist?list=PLuNxlOYbv61h66_QlcjCEkVAj6RdeplJJ) 66 | * [Spring Boot : The Missing Guide](https://www.youtube.com/playlist?list=PLuNxlOYbv61jZL1IiciTgWezZoqEp4WXh) 67 | * [Java Testing Made Easy: Learn writing Unit, Integration, E2E & Performance Tests](https://www.youtube.com/playlist?list=PLuNxlOYbv61jtHHFHBOc9N7Dg5jn013ix) 68 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | vars: 4 | GOOS: "{{default OS .GOOS}}" 5 | MVNW: '{{if eq .GOOS "windows"}}mvnw.cmd{{else}}./mvnw{{end}}' 6 | DC_DIR: "deployment/docker-compose" 7 | INFRA_DC_FILE: "{{.DC_DIR}}/infra.yml" 8 | APPS_DC_FILE: "{{.DC_DIR}}/apps.yml" 9 | MONITORING_DC_FILE: "{{.DC_DIR}}/monitoring.yml" 10 | SLEEP_CMD: '{{if eq .GOOS "windows"}}timeout{{else}}sleep{{end}}' 11 | 12 | tasks: 13 | default: 14 | cmds: 15 | - task: test 16 | test: 17 | deps: [format] 18 | cmds: 19 | - "{{.MVNW}} clean verify" 20 | 21 | format: 22 | cmds: 23 | - "{{.MVNW}} spotless:apply" 24 | 25 | build: 26 | cmds: 27 | - "{{.MVNW}} -pl catalog-service spring-boot:build-image -DskipTests" 28 | - "{{.MVNW}} -pl order-service spring-boot:build-image -DskipTests" 29 | - "{{.MVNW}} -pl notification-service spring-boot:build-image -DskipTests" 30 | - "{{.MVNW}} -pl api-gateway spring-boot:build-image -DskipTests" 31 | - "{{.MVNW}} -pl bookstore-webapp spring-boot:build-image -DskipTests" 32 | 33 | start_infra: 34 | cmds: 35 | - "docker compose -f {{.INFRA_DC_FILE}} up -d" 36 | 37 | stop_infra: 38 | cmds: 39 | - "docker compose -f {{.INFRA_DC_FILE}} stop" 40 | - "docker compose -f {{.INFRA_DC_FILE}} rm -f" 41 | 42 | restart_infra: 43 | cmds: 44 | - task: stop_infra 45 | - task: sleep 46 | - task: start_infra 47 | 48 | start_monitoring: 49 | cmds: 50 | - "docker compose -f {{.MONITORING_DC_FILE}} up -d" 51 | 52 | stop_monitoring: 53 | cmds: 54 | - "docker compose -f {{.MONITORING_DC_FILE}} stop" 55 | - "docker compose -f {{.MONITORING_DC_FILE}} rm -f" 56 | 57 | restart_monitoring: 58 | cmds: 59 | - task: stop_monitoring 60 | - task: sleep 61 | - task: start_monitoring 62 | 63 | start: 64 | deps: [build] 65 | cmds: 66 | - "docker compose -f {{.INFRA_DC_FILE}} -f {{.APPS_DC_FILE}} up -d" 67 | 68 | stop: 69 | cmds: 70 | - "docker compose -f {{.INFRA_DC_FILE}} -f {{.APPS_DC_FILE}} stop" 71 | - "docker compose -f {{.INFRA_DC_FILE}} -f {{.APPS_DC_FILE}} rm -f" 72 | 73 | restart: 74 | cmds: 75 | - task: stop 76 | - task: sleep 77 | - task: start 78 | 79 | sleep: 80 | vars: 81 | DURATION: "{{default 5 .DURATION}}" 82 | cmds: 83 | - "{{.SLEEP_CMD}} {{.DURATION}}" 84 | -------------------------------------------------------------------------------- /api-gateway/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /api-gateway/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/api-gateway/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /api-gateway/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /api-gateway/src/main/java/com/sivalabs/bookstore/gateway/ApiGatewayApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.gateway; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class ApiGatewayApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(ApiGatewayApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /api-gateway/src/main/java/com/sivalabs/bookstore/gateway/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.gateway; 2 | 3 | import static org.springdoc.core.utils.Constants.DEFAULT_API_DOCS_URL; 4 | 5 | import jakarta.annotation.PostConstruct; 6 | import java.util.HashSet; 7 | import java.util.List; 8 | import java.util.Set; 9 | import org.springdoc.core.properties.AbstractSwaggerUiConfigProperties; 10 | import org.springdoc.core.properties.SwaggerUiConfigProperties; 11 | import org.springframework.cloud.gateway.route.RouteDefinition; 12 | import org.springframework.cloud.gateway.route.RouteDefinitionLocator; 13 | import org.springframework.context.annotation.Configuration; 14 | 15 | @Configuration 16 | class SwaggerConfig { 17 | private final RouteDefinitionLocator locator; 18 | private final SwaggerUiConfigProperties swaggerUiConfigProperties; 19 | 20 | public SwaggerConfig(RouteDefinitionLocator locator, SwaggerUiConfigProperties swaggerUiConfigProperties) { 21 | this.locator = locator; 22 | this.swaggerUiConfigProperties = swaggerUiConfigProperties; 23 | } 24 | 25 | @PostConstruct 26 | public void init() { 27 | List definitions = 28 | locator.getRouteDefinitions().collectList().block(); 29 | Set urls = new HashSet<>(); 30 | definitions.stream() 31 | .filter(routeDefinition -> routeDefinition.getId().matches(".*-service")) 32 | .forEach(routeDefinition -> { 33 | String name = routeDefinition.getId().replaceAll("-service", ""); 34 | AbstractSwaggerUiConfigProperties.SwaggerUrl swaggerUrl = 35 | new AbstractSwaggerUiConfigProperties.SwaggerUrl( 36 | name, DEFAULT_API_DOCS_URL + "/" + name, null); 37 | urls.add(swaggerUrl); 38 | }); 39 | swaggerUiConfigProperties.setUrls(urls); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /api-gateway/src/main/resources/application.properties.bak: -------------------------------------------------------------------------------- 1 | spring.application.name=api-gateway 2 | server.port=8989 3 | server.shutdown=graceful 4 | spring.mvc.problemdetails.enabled=true 5 | 6 | ######## API Gateway Application Configuration ######### 7 | spring.cloud.gateway.discovery.locator.enabled=true 8 | spring.cloud.gateway.globalcors.cors-configurations["[/**]"].allowed-origins="*" 9 | spring.cloud.gateway.globalcors.cors-configurations["[/**]"].allowed-methods="*" 10 | spring.cloud.gateway.globalcors.cors-configurations["[/**]"].allowed-headers="*" 11 | spring.cloud.gateway.globalcors.cors-configurations["[/**]"].allow-credentials=true 12 | 13 | spring.cloud.gateway.routes[0].id=catalog-service 14 | spring.cloud.gateway.routes[0].uri=${CATALOG_SERVICE_URL:http://localhost:8081} 15 | spring.cloud.gateway.routes[0].predicates[0]=Path=/catalog/** 16 | spring.cloud.gateway.routes[0].filters[0]=RewritePath=/catalog/?(?.*), /${segment} 17 | 18 | spring.cloud.gateway.routes[1].id=orders-service 19 | spring.cloud.gateway.routes[1].uri=${ORDER_SERVICE_URL:http://localhost:8082} 20 | spring.cloud.gateway.routes[1].predicates[0]=Path=/orders/** 21 | spring.cloud.gateway.routes[1].filters[0]=RewritePath=/orders/?(?.*), /${segment} 22 | 23 | spring.cloud.gateway.routes[2].id=openapi 24 | spring.cloud.gateway.routes[2].uri=http://localhost:${server.port} 25 | spring.cloud.gateway.routes[2].predicates[0]=Path=/v3/api-docs/** 26 | spring.cloud.gateway.routes[2].filters[0]=RewritePath=/v3/api-docs/?(?.*), /${segment}/v3/api-docs 27 | 28 | ######## Actuator Configuration ######### 29 | management.info.git.mode=full 30 | management.endpoints.web.exposure.include=* 31 | management.metrics.tags.application=${spring.application.name} 32 | management.tracing.enabled=false 33 | management.tracing.sampling.probability=1.0 34 | 35 | ######## Swagger Configuration ######### 36 | springdoc.swagger-ui.use-root-path=true 37 | #springdoc.swagger-ui.urls[0].name=catalog 38 | #springdoc.swagger-ui.urls[0].url=/v3/api-docs/catalog 39 | #springdoc.swagger-ui.urls[1].name=orders 40 | #springdoc.swagger-ui.urls[1].url=/v3/api-docs/orders 41 | 42 | -------------------------------------------------------------------------------- /api-gateway/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8989 3 | shutdown: graceful 4 | 5 | management: 6 | endpoints: 7 | web: 8 | exposure: 9 | include: "*" 10 | metrics: 11 | tags: 12 | application: ${spring.application.name} 13 | tracing: 14 | enabled: false 15 | sampling: 16 | probability: 1.0 17 | 18 | spring: 19 | application: 20 | name: api-gateway 21 | cloud: 22 | gateway: 23 | discovery: 24 | locator: 25 | enabled: true 26 | globalcors: 27 | cors-configurations: 28 | '[/**]': 29 | allowed-origins: "*" 30 | allowed-methods: "*" 31 | allowed-headers: "*" 32 | allow-credentials: false 33 | routes: 34 | - id: catalog-service 35 | uri: ${CATALOG_SERVICE_URL:http://localhost:8081} 36 | predicates: 37 | - Path=/catalog/** 38 | filters: 39 | - RewritePath=/catalog/?(?.*), /${segment} 40 | - id: orders-service 41 | uri: ${ORDER_SERVICE_URL:http://localhost:8082} 42 | predicates: 43 | - Path=/orders/** 44 | filters: 45 | - RewritePath=/orders/?(?.*), /${segment} 46 | - id: openapi 47 | uri: http://localhost:${server.port} 48 | predicates: 49 | - Path=/v3/api-docs/** 50 | filters: 51 | - RewritePath=/v3/api-docs/?(?.*), /${segment}/v3/api-docs 52 | default-filters: 53 | - DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Methods Access-Control-Allow-Headers RETAIN_FIRST 54 | 55 | mvc: 56 | problemdetails: 57 | enabled: true 58 | 59 | springdoc: 60 | swagger-ui: 61 | use-root-path: true 62 | # urls: 63 | # - name: catalog 64 | # url: /v3/api-docs/catalog 65 | # - name: orders 66 | # url: /v3/api-docs/orders 67 | -------------------------------------------------------------------------------- /api-gateway/src/test/java/com/sivalabs/bookstore/gateway/ApiGatewayApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.gateway; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class ApiGatewayApplicationTests { 8 | 9 | @Test 10 | void contextLoads() {} 11 | } 12 | -------------------------------------------------------------------------------- /bookstore-webapp/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /bookstore-webapp/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/bookstore-webapp/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /bookstore-webapp/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/ApplicationProperties.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | 5 | @ConfigurationProperties(prefix = "bookstore") 6 | public record ApplicationProperties(String apiGatewayUrl) {} 7 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/BookstoreWebappApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | 7 | @SpringBootApplication 8 | @ConfigurationPropertiesScan 9 | public class BookstoreWebappApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(BookstoreWebappApplication.class, args); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/ClientsConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients; 2 | 3 | import com.sivalabs.bookstore.webapp.ApplicationProperties; 4 | import com.sivalabs.bookstore.webapp.clients.catalog.CatalogServiceClient; 5 | import com.sivalabs.bookstore.webapp.clients.orders.OrderServiceClient; 6 | import java.time.Duration; 7 | import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; 8 | import org.springframework.boot.web.client.RestClientCustomizer; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.web.client.RestClient; 12 | import org.springframework.web.client.support.RestClientAdapter; 13 | import org.springframework.web.service.invoker.HttpServiceProxyFactory; 14 | 15 | @Configuration 16 | class ClientsConfig { 17 | private final ApplicationProperties properties; 18 | 19 | ClientsConfig(ApplicationProperties properties) { 20 | this.properties = properties; 21 | } 22 | 23 | @Bean 24 | RestClientCustomizer restClientCustomizer() { 25 | var requestFactory = ClientHttpRequestFactoryBuilder.simple() 26 | .withCustomizer(c -> { 27 | c.setConnectTimeout(Duration.ofSeconds(5)); 28 | c.setReadTimeout(Duration.ofSeconds(5)); 29 | }) 30 | .build(); 31 | return restClientBuilder -> 32 | restClientBuilder.baseUrl(properties.apiGatewayUrl()).requestFactory(requestFactory); 33 | } 34 | 35 | @Bean 36 | CatalogServiceClient catalogServiceClient(RestClient.Builder builder) { 37 | RestClient restClient = builder.build(); 38 | HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)) 39 | .build(); 40 | return factory.createClient(CatalogServiceClient.class); 41 | } 42 | 43 | @Bean 44 | OrderServiceClient orderServiceClient(RestClient.Builder builder) { 45 | RestClient restClient = builder.build(); 46 | HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)) 47 | .build(); 48 | return factory.createClient(OrderServiceClient.class); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/catalog/CatalogServiceClient.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.catalog; 2 | 3 | import org.springframework.http.ResponseEntity; 4 | import org.springframework.web.bind.annotation.PathVariable; 5 | import org.springframework.web.bind.annotation.RequestParam; 6 | import org.springframework.web.service.annotation.GetExchange; 7 | 8 | public interface CatalogServiceClient { 9 | 10 | @GetExchange("/catalog/api/products") 11 | PagedResult getProducts(@RequestParam int page); 12 | 13 | @GetExchange("/catalog/api/products/{code}") 14 | ResponseEntity getProductByCode(@PathVariable String code); 15 | } 16 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/catalog/PagedResult.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.catalog; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import java.util.List; 5 | 6 | public record PagedResult( 7 | List data, 8 | long totalElements, 9 | int pageNumber, 10 | int totalPages, 11 | @JsonProperty("isFirst") boolean isFirst, 12 | @JsonProperty("isLast") boolean isLast, 13 | @JsonProperty("hasNext") boolean hasNext, 14 | @JsonProperty("hasPrevious") boolean hasPrevious) {} 15 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/catalog/Product.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.catalog; 2 | 3 | import java.math.BigDecimal; 4 | 5 | public record Product(Long id, String code, String name, String description, String imageUrl, BigDecimal price) {} 6 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/orders/Address.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.orders; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | import java.io.Serializable; 5 | 6 | public record Address( 7 | @NotBlank(message = "AddressLine1 is required") String addressLine1, 8 | String addressLine2, 9 | @NotBlank(message = "City is required") String city, 10 | @NotBlank(message = "State is required") String state, 11 | @NotBlank(message = "ZipCode is required") String zipCode, 12 | @NotBlank(message = "Country is required") String country) 13 | implements Serializable {} 14 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/orders/CreateOrderRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.orders; 2 | 3 | import jakarta.validation.Valid; 4 | import jakarta.validation.constraints.NotBlank; 5 | import java.io.Serializable; 6 | import java.util.Set; 7 | 8 | public record CreateOrderRequest( 9 | @NotBlank(message = "Items cannot be empty.") Set items, 10 | @Valid Customer customer, 11 | @Valid Address deliveryAddress) 12 | implements Serializable {} 13 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/orders/Customer.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.orders; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import jakarta.validation.constraints.NotBlank; 5 | import java.io.Serializable; 6 | 7 | public record Customer( 8 | @NotBlank(message = "Customer Name is required") String name, 9 | @NotBlank(message = "Customer email is required") @Email String email, 10 | @NotBlank(message = "Customer Phone number is required") String phone) 11 | implements Serializable {} 12 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/orders/OrderConfirmationDTO.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.orders; 2 | 3 | public record OrderConfirmationDTO(String orderNumber, OrderStatus status) {} 4 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/orders/OrderDTO.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.orders; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import java.math.BigDecimal; 5 | import java.util.Set; 6 | 7 | public record OrderDTO( 8 | Long id, 9 | String orderNumber, 10 | Set items, 11 | Customer customer, 12 | Address deliveryAddress, 13 | OrderStatus status, 14 | String comments) { 15 | 16 | @JsonProperty(access = JsonProperty.Access.READ_ONLY) 17 | public BigDecimal getTotalAmount() { 18 | return items.stream() 19 | .map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity()))) 20 | .reduce(BigDecimal.ZERO, BigDecimal::add); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/orders/OrderItem.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.orders; 2 | 3 | import jakarta.validation.constraints.Min; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.NotNull; 6 | import java.io.Serializable; 7 | import java.math.BigDecimal; 8 | 9 | public record OrderItem( 10 | @NotBlank(message = "Code is required") String code, 11 | @NotBlank(message = "Name is required") String name, 12 | @NotNull(message = "Price is required") BigDecimal price, 13 | @NotNull @Min(1) Integer quantity) 14 | implements Serializable {} 15 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/orders/OrderServiceClient.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.orders; 2 | 3 | import java.util.List; 4 | import java.util.Map; 5 | import org.springframework.web.bind.annotation.PathVariable; 6 | import org.springframework.web.bind.annotation.RequestBody; 7 | import org.springframework.web.bind.annotation.RequestHeader; 8 | import org.springframework.web.service.annotation.GetExchange; 9 | import org.springframework.web.service.annotation.PostExchange; 10 | 11 | public interface OrderServiceClient { 12 | @PostExchange("/orders/api/orders") 13 | OrderConfirmationDTO createOrder( 14 | @RequestHeader Map headers, @RequestBody CreateOrderRequest orderRequest); 15 | 16 | @GetExchange("/orders/api/orders") 17 | List getOrders(@RequestHeader Map headers); 18 | 19 | @GetExchange("/orders/api/orders/{orderNumber}") 20 | OrderDTO getOrder(@RequestHeader Map headers, @PathVariable String orderNumber); 21 | } 22 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/orders/OrderStatus.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.orders; 2 | 3 | public enum OrderStatus { 4 | NEW, 5 | IN_PROCESS, 6 | DELIVERED, 7 | CANCELLED, 8 | ERROR 9 | } 10 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/clients/orders/OrderSummary.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.clients.orders; 2 | 3 | public record OrderSummary(String orderNumber, OrderStatus status) {} 4 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.security.config.Customizer; 6 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 7 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 8 | import org.springframework.security.config.annotation.web.configurers.CorsConfigurer; 9 | import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; 10 | import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; 11 | import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; 12 | import org.springframework.security.web.SecurityFilterChain; 13 | import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; 14 | 15 | @Configuration 16 | @EnableWebSecurity 17 | class SecurityConfig { 18 | private final ClientRegistrationRepository clientRegistrationRepository; 19 | 20 | SecurityConfig(ClientRegistrationRepository clientRegistrationRepository) { 21 | this.clientRegistrationRepository = clientRegistrationRepository; 22 | } 23 | 24 | @Bean 25 | SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 26 | http.authorizeHttpRequests(c -> c.requestMatchers( 27 | "/js/*", 28 | "/css/*", 29 | "/images/*", 30 | "/error", 31 | "/webjars/**", 32 | "/", 33 | "/actuator/**", 34 | "/products/**", 35 | "/api/products/**") 36 | .permitAll() 37 | .anyRequest() 38 | .authenticated()) 39 | .cors(CorsConfigurer::disable) 40 | .csrf(CsrfConfigurer::disable) 41 | .oauth2Login(Customizer.withDefaults()) 42 | .logout(logout -> logout.clearAuthentication(true) 43 | .invalidateHttpSession(true) 44 | .logoutSuccessHandler(oidcLogoutSuccessHandler())); 45 | return http.build(); 46 | } 47 | 48 | private LogoutSuccessHandler oidcLogoutSuccessHandler() { 49 | OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = 50 | new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository); 51 | oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}"); 52 | return oidcLogoutSuccessHandler; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/services/SecurityHelper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.services; 2 | 3 | import org.springframework.security.core.Authentication; 4 | import org.springframework.security.core.context.SecurityContextHolder; 5 | import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; 6 | import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; 7 | import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; 8 | import org.springframework.stereotype.Service; 9 | 10 | @Service 11 | public class SecurityHelper { 12 | private final OAuth2AuthorizedClientService authorizedClientService; 13 | 14 | public SecurityHelper(OAuth2AuthorizedClientService authorizedClientService) { 15 | this.authorizedClientService = authorizedClientService; 16 | } 17 | 18 | public String getAccessToken() { 19 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 20 | if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { 21 | return null; 22 | } 23 | OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient( 24 | oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName()); 25 | 26 | return client.getAccessToken().getTokenValue(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/web/controllers/OrderController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.web.controllers; 2 | 3 | import com.sivalabs.bookstore.webapp.clients.orders.CreateOrderRequest; 4 | import com.sivalabs.bookstore.webapp.clients.orders.OrderConfirmationDTO; 5 | import com.sivalabs.bookstore.webapp.clients.orders.OrderDTO; 6 | import com.sivalabs.bookstore.webapp.clients.orders.OrderServiceClient; 7 | import com.sivalabs.bookstore.webapp.clients.orders.OrderSummary; 8 | import com.sivalabs.bookstore.webapp.services.SecurityHelper; 9 | import jakarta.validation.Valid; 10 | import java.util.List; 11 | import java.util.Map; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | import org.springframework.stereotype.Controller; 15 | import org.springframework.ui.Model; 16 | import org.springframework.web.bind.annotation.GetMapping; 17 | import org.springframework.web.bind.annotation.PathVariable; 18 | import org.springframework.web.bind.annotation.PostMapping; 19 | import org.springframework.web.bind.annotation.RequestBody; 20 | import org.springframework.web.bind.annotation.ResponseBody; 21 | 22 | @Controller 23 | class OrderController { 24 | private static final Logger log = LoggerFactory.getLogger(OrderController.class); 25 | private final OrderServiceClient orderServiceClient; 26 | private final SecurityHelper securityHelper; 27 | 28 | OrderController(OrderServiceClient orderServiceClient, SecurityHelper securityHelper) { 29 | this.orderServiceClient = orderServiceClient; 30 | this.securityHelper = securityHelper; 31 | } 32 | 33 | @GetMapping("/cart") 34 | String cart() { 35 | return "cart"; 36 | } 37 | 38 | @PostMapping("/api/orders") 39 | @ResponseBody 40 | OrderConfirmationDTO createOrder(@Valid @RequestBody CreateOrderRequest orderRequest) { 41 | log.info("Creating order: {}", orderRequest); 42 | return orderServiceClient.createOrder(getHeaders(), orderRequest); 43 | } 44 | 45 | @GetMapping("/orders/{orderNumber}") 46 | String showOrderDetails(@PathVariable String orderNumber, Model model) { 47 | model.addAttribute("orderNumber", orderNumber); 48 | return "order_details"; 49 | } 50 | 51 | @GetMapping("/api/orders/{orderNumber}") 52 | @ResponseBody 53 | OrderDTO getOrder(@PathVariable String orderNumber) { 54 | log.info("Fetching order details for orderNumber: {}", orderNumber); 55 | return orderServiceClient.getOrder(getHeaders(), orderNumber); 56 | } 57 | 58 | @GetMapping("/orders") 59 | String showOrders() { 60 | return "orders"; 61 | } 62 | 63 | @GetMapping("/api/orders") 64 | @ResponseBody 65 | List getOrders() { 66 | log.info("Fetching orders"); 67 | return orderServiceClient.getOrders(getHeaders()); 68 | } 69 | 70 | private Map getHeaders() { 71 | String accessToken = securityHelper.getAccessToken(); 72 | return Map.of("Authorization", "Bearer " + accessToken); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/java/com/sivalabs/bookstore/webapp/web/controllers/ProductController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.webapp.web.controllers; 2 | 3 | import com.sivalabs.bookstore.webapp.clients.catalog.CatalogServiceClient; 4 | import com.sivalabs.bookstore.webapp.clients.catalog.PagedResult; 5 | import com.sivalabs.bookstore.webapp.clients.catalog.Product; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.stereotype.Controller; 9 | import org.springframework.ui.Model; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.RequestParam; 12 | import org.springframework.web.bind.annotation.ResponseBody; 13 | 14 | @Controller 15 | class ProductController { 16 | private static final Logger log = LoggerFactory.getLogger(ProductController.class); 17 | private final CatalogServiceClient catalogService; 18 | 19 | ProductController(CatalogServiceClient catalogService) { 20 | this.catalogService = catalogService; 21 | } 22 | 23 | @GetMapping 24 | String index() { 25 | return "redirect:/products"; 26 | } 27 | 28 | @GetMapping("/products") 29 | String showProductsPage(@RequestParam(name = "page", defaultValue = "1") int page, Model model) { 30 | model.addAttribute("pageNo", page); 31 | return "products"; 32 | } 33 | 34 | @GetMapping("/api/products") 35 | @ResponseBody 36 | PagedResult products(@RequestParam(name = "page", defaultValue = "1") int page, Model model) { 37 | log.info("Fetching products for page: {}", page); 38 | return catalogService.getProducts(page); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=bookstore-webapp 2 | server.port=8080 3 | server.shutdown=graceful 4 | spring.mvc.problemdetails.enabled=true 5 | 6 | ######## BookStore Application Configuration ######### 7 | bookstore.api-gateway-url=http://localhost:8989 8 | 9 | ######## Actuator Configuration ######### 10 | management.info.git.mode=full 11 | management.endpoints.web.exposure.include=* 12 | management.metrics.tags.application=${spring.application.name} 13 | management.tracing.enabled=false 14 | management.tracing.sampling.probability=1.0 15 | 16 | ######## OAuth2 Security Configuration ######### 17 | OAUTH2_SERVER_URL=http://localhost:9191 18 | REALM_URL=${OAUTH2_SERVER_URL}/realms/bookstore 19 | 20 | spring.security.oauth2.client.registration.bookstore-webapp.client-id=bookstore-webapp 21 | spring.security.oauth2.client.registration.bookstore-webapp.client-secret=P1sibsIrELBhmvK18BOzw1bUl96DcP2z 22 | spring.security.oauth2.client.registration.bookstore-webapp.authorization-grant-type=authorization_code 23 | spring.security.oauth2.client.registration.bookstore-webapp.scope=openid, profile 24 | spring.security.oauth2.client.registration.bookstore-webapp.redirect-uri={baseUrl}/login/oauth2/code/bookstore-webapp 25 | 26 | spring.security.oauth2.client.provider.bookstore-webapp.issuer-uri=${REALM_URL} 27 | #spring.security.oauth2.client.provider.bookstore-webapp.authorization-uri=${REALM_URL}/protocol/openid-connect/auth 28 | #spring.security.oauth2.client.provider.bookstore-webapp.token-uri=${REALM_URL}/protocol/openid-connect/token 29 | #spring.security.oauth2.client.provider.bookstore-webapp.jwk-set-uri=${REALM_URL}/protocol/openid-connect/certs 30 | #spring.security.oauth2.client.provider.bookstore-webapp.user-info-uri=${REALM_URL}/protocol/openid-connect/userinfo 31 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/static/css/styles.css: -------------------------------------------------------------------------------- 1 | #app { 2 | padding-top: 90px; 3 | } -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/static/images/books.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/bookstore-webapp/src/main/resources/static/images/books.png -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/static/js/cart.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('alpine:init', () => { 2 | Alpine.data('initData', () => ({ 3 | cart: { items: [], totalAmount: 0 }, 4 | orderForm: { 5 | customer: { 6 | name: "Siva", 7 | email: "siva@gmail.com", 8 | phone: "999999999999" 9 | }, 10 | deliveryAddress: { 11 | addressLine1: "KPHB", 12 | addressLine2: "Kukatpally", 13 | city:"Hyderabad", 14 | state: "TS", 15 | zipCode: "500072", 16 | country: "India" 17 | } 18 | }, 19 | 20 | init() { 21 | updateCartItemCount(); 22 | this.loadCart(); 23 | this.cart.totalAmount = getCartTotal(); 24 | }, 25 | loadCart() { 26 | this.cart = getCart() 27 | }, 28 | updateItemQuantity(code, quantity) { 29 | updateProductQuantity(code, quantity); 30 | this.loadCart(); 31 | this.cart.totalAmount = getCartTotal(); 32 | }, 33 | removeCart() { 34 | deleteCart(); 35 | }, 36 | createOrder() { 37 | let order = Object.assign({}, this.orderForm, {items: this.cart.items}); 38 | //console.log("Order ", order); 39 | 40 | $.ajax ({ 41 | url: '/api/orders', 42 | type: "POST", 43 | dataType: "json", 44 | contentType: "application/json", 45 | data : JSON.stringify(order), 46 | success: (resp) => { 47 | //console.log("Order Resp:", resp) 48 | this.removeCart(); 49 | //alert("Order placed successfully") 50 | window.location = "/orders/"+resp.orderNumber; 51 | }, error: (err) => { 52 | console.log("Order Creation Error:", err) 53 | alert("Order creation failed") 54 | } 55 | }); 56 | }, 57 | })) 58 | }); 59 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/static/js/cartStore.js: -------------------------------------------------------------------------------- 1 | const BOOKSTORE_STATE_KEY = "BOOKSTORE_STATE"; 2 | 3 | const getCart = function() { 4 | let cart = localStorage.getItem(BOOKSTORE_STATE_KEY) 5 | if (!cart) { 6 | cart = JSON.stringify({items:[], totalAmount:0 }); 7 | localStorage.setItem(BOOKSTORE_STATE_KEY, cart) 8 | } 9 | return JSON.parse(cart) 10 | } 11 | 12 | const addProductToCart = function(product) { 13 | let cart = getCart(); 14 | let cartItem = cart.items.find(itemModel => itemModel.code === product.code); 15 | if (cartItem) { 16 | cartItem.quantity = parseInt(cartItem.quantity) + 1; 17 | } else { 18 | cart.items.push(Object.assign({}, product, {quantity: 1})); 19 | } 20 | localStorage.setItem(BOOKSTORE_STATE_KEY, JSON.stringify(cart)); 21 | updateCartItemCount(); 22 | } 23 | 24 | const updateProductQuantity = function(code, quantity) { 25 | let cart = getCart(); 26 | if(quantity < 1) { 27 | cart.items = cart.items.filter(itemModel => itemModel.code !== code); 28 | } else { 29 | let cartItem = cart.items.find(itemModel => itemModel.code === code); 30 | if (cartItem) { 31 | cartItem.quantity = parseInt(quantity); 32 | } else { 33 | console.log("Product code is not already in Cart, ignoring") 34 | } 35 | } 36 | localStorage.setItem(BOOKSTORE_STATE_KEY, JSON.stringify(cart)); 37 | updateCartItemCount(); 38 | } 39 | 40 | const deleteCart = function() { 41 | localStorage.removeItem(BOOKSTORE_STATE_KEY) 42 | updateCartItemCount(); 43 | } 44 | 45 | function updateCartItemCount() { 46 | let cart = getCart(); 47 | let count = 0; 48 | cart.items.forEach(item => { 49 | count = count + item.quantity; 50 | }); 51 | $('#cart-item-count').text('(' + count + ')'); 52 | } 53 | 54 | function getCartTotal() { 55 | let cart = getCart(); 56 | let totalAmount = 0; 57 | cart.items.forEach(item => { 58 | totalAmount = totalAmount + (item.price * item.quantity); 59 | }); 60 | return totalAmount; 61 | } -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/static/js/orderDetails.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('alpine:init', () => { 2 | Alpine.data('initData', (orderNumber) => ({ 3 | orderNumber: orderNumber, 4 | orderDetails: { 5 | items: [], 6 | customer: {}, 7 | deliveryAddress: {} 8 | }, 9 | init() { 10 | updateCartItemCount(); 11 | this.getOrderDetails(this.orderNumber) 12 | }, 13 | getOrderDetails(orderNumber) { 14 | $.getJSON("/api/orders/"+ orderNumber, (data) => { 15 | //console.log("Get Order Resp:", data) 16 | this.orderDetails = data 17 | }); 18 | } 19 | })) 20 | }); 21 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/static/js/orders.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('alpine:init', () => { 2 | Alpine.data('initData', () => ({ 3 | orders: [], 4 | init() { 5 | this.loadOrders(); 6 | updateCartItemCount(); 7 | }, 8 | loadOrders() { 9 | $.getJSON("/api/orders", (data) => { 10 | //console.log("orders :", data) 11 | this.orders = data 12 | }); 13 | }, 14 | })) 15 | }); 16 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/static/js/products.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('alpine:init', () => { 2 | Alpine.data('initData', (pageNo) => ({ 3 | pageNo: pageNo, 4 | products: { 5 | data: [] 6 | }, 7 | init() { 8 | this.loadProducts(this.pageNo); 9 | updateCartItemCount(); 10 | }, 11 | loadProducts(pageNo) { 12 | $.getJSON("/api/products?page="+pageNo, (resp)=> { 13 | console.log("Products Resp:", resp) 14 | this.products = resp; 15 | }); 16 | }, 17 | addToCart(product) { 18 | addProductToCart(product) 19 | } 20 | })) 21 | }); -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/templates/fragments/pagination.html: -------------------------------------------------------------------------------- 1 |
2 | 23 |
-------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | BookStore 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 55 | 56 |
57 |
58 | 59 |
60 |
61 |
62 | 63 | 64 | 65 | 66 | 67 | 72 |
73 |
74 | 75 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/templates/orders.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 |
10 |
11 |

All Orders

12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 28 | 29 |
Order IDStatus
30 |
31 |
32 |
33 |
34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /bookstore-webapp/src/main/resources/templates/products.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 |
9 |
10 |
11 |
12 | 42 |
43 |
44 |
45 |
46 | 47 |
48 | 49 | -------------------------------------------------------------------------------- /catalog-service/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /catalog-service/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/catalog-service/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /catalog-service/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/ApplicationProperties.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog; 2 | 3 | import jakarta.validation.constraints.Min; 4 | import org.springframework.boot.context.properties.ConfigurationProperties; 5 | import org.springframework.boot.context.properties.bind.DefaultValue; 6 | import org.springframework.validation.annotation.Validated; 7 | 8 | @Validated 9 | @ConfigurationProperties(prefix = "catalog") 10 | public record ApplicationProperties(@DefaultValue("10") @Min(1) int pageSize) {} 11 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/CatalogServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | 7 | @SpringBootApplication 8 | @ConfigurationPropertiesScan 9 | public class CatalogServiceApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(CatalogServiceApplication.class, args); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/config/OpenAPI3Configuration.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.config; 2 | 3 | import io.swagger.v3.oas.models.OpenAPI; 4 | import io.swagger.v3.oas.models.info.Contact; 5 | import io.swagger.v3.oas.models.info.Info; 6 | import io.swagger.v3.oas.models.servers.Server; 7 | import java.util.List; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | 12 | @Configuration 13 | class OpenAPI3Configuration { 14 | 15 | @Value("${swagger.api-gateway-url}") 16 | String apiGatewayUrl; 17 | 18 | @Bean 19 | OpenAPI openApi() { 20 | return new OpenAPI() 21 | .info(new Info() 22 | .title("Catalog Service APIs") 23 | .description("BookStore Catalog Service APIs") 24 | .version("v1.0.0") 25 | .contact(new Contact().name("SivaLabs").email("sivalabs@sivalabs.in"))) 26 | .servers(List.of(new Server().url(apiGatewayUrl))); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/config/WebMvcConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.web.servlet.config.annotation.CorsRegistry; 5 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 6 | 7 | @Configuration 8 | class WebMvcConfig implements WebMvcConfigurer { 9 | 10 | @Override 11 | public void addCorsMappings(CorsRegistry registry) { 12 | registry.addMapping("/api/**") 13 | .allowedMethods("*") 14 | .allowedHeaders("*") 15 | .allowedOriginPatterns("*") 16 | .allowCredentials(false); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/domain/PagedResult.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | import java.util.List; 4 | 5 | public record PagedResult( 6 | List data, 7 | long totalElements, 8 | int pageNumber, 9 | int totalPages, 10 | boolean isFirst, 11 | boolean isLast, 12 | boolean hasNext, 13 | boolean hasPrevious) {} 14 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/domain/Product.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | import java.math.BigDecimal; 4 | 5 | public record Product(String code, String name, String description, String imageUrl, BigDecimal price) {} 6 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/domain/ProductEntity.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.GeneratedValue; 6 | import jakarta.persistence.GenerationType; 7 | import jakarta.persistence.Id; 8 | import jakarta.persistence.SequenceGenerator; 9 | import jakarta.persistence.Table; 10 | import jakarta.validation.constraints.DecimalMin; 11 | import jakarta.validation.constraints.NotBlank; 12 | import jakarta.validation.constraints.NotNull; 13 | import java.math.BigDecimal; 14 | 15 | @Entity 16 | @Table(name = "products") 17 | class ProductEntity { 18 | @Id 19 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_id_generator") 20 | @SequenceGenerator(name = "product_id_generator", sequenceName = "product_id_seq") 21 | private Long id; 22 | 23 | @Column(nullable = false, unique = true) 24 | @NotBlank(message = "Product code is required") private String code; 25 | 26 | @NotBlank(message = "Product name is required") @Column(nullable = false) 27 | private String name; 28 | 29 | private String description; 30 | 31 | private String imageUrl; 32 | 33 | @NotNull(message = "Product price is required") @DecimalMin("0.1") @Column(nullable = false) 34 | private BigDecimal price; 35 | 36 | public ProductEntity() {} 37 | 38 | public ProductEntity(Long id, String code, String name, String description, String imageUrl, BigDecimal price) { 39 | this.id = id; 40 | this.code = code; 41 | this.name = name; 42 | this.description = description; 43 | this.imageUrl = imageUrl; 44 | this.price = price; 45 | } 46 | 47 | public Long getId() { 48 | return id; 49 | } 50 | 51 | public void setId(Long id) { 52 | this.id = id; 53 | } 54 | 55 | public String getCode() { 56 | return code; 57 | } 58 | 59 | public void setCode(String code) { 60 | this.code = code; 61 | } 62 | 63 | public String getName() { 64 | return name; 65 | } 66 | 67 | public void setName(String name) { 68 | this.name = name; 69 | } 70 | 71 | public String getDescription() { 72 | return description; 73 | } 74 | 75 | public void setDescription(String description) { 76 | this.description = description; 77 | } 78 | 79 | public String getImageUrl() { 80 | return imageUrl; 81 | } 82 | 83 | public void setImageUrl(String imageUrl) { 84 | this.imageUrl = imageUrl; 85 | } 86 | 87 | public BigDecimal getPrice() { 88 | return price; 89 | } 90 | 91 | public void setPrice(BigDecimal price) { 92 | this.price = price; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/domain/ProductMapper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | class ProductMapper { 4 | 5 | static Product toProduct(ProductEntity productEntity) { 6 | return new Product( 7 | productEntity.getCode(), 8 | productEntity.getName(), 9 | productEntity.getDescription(), 10 | productEntity.getImageUrl(), 11 | productEntity.getPrice()); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/domain/ProductNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | public class ProductNotFoundException extends RuntimeException { 4 | public ProductNotFoundException(String message) { 5 | super(message); 6 | } 7 | 8 | public static ProductNotFoundException forCode(String code) { 9 | return new ProductNotFoundException("Product with code " + code + " not found"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/domain/ProductRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | import java.util.Optional; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | interface ProductRepository extends JpaRepository { 7 | Optional findByCode(String code); 8 | } 9 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/domain/ProductService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | import com.sivalabs.bookstore.catalog.ApplicationProperties; 4 | import java.util.Optional; 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.PageRequest; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.domain.Sort; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.transaction.annotation.Transactional; 11 | 12 | @Service 13 | @Transactional 14 | public class ProductService { 15 | private final ProductRepository productRepository; 16 | private final ApplicationProperties properties; 17 | 18 | ProductService(ProductRepository productRepository, ApplicationProperties properties) { 19 | this.productRepository = productRepository; 20 | this.properties = properties; 21 | } 22 | 23 | public PagedResult getProducts(int pageNo) { 24 | Sort sort = Sort.by("name").ascending(); 25 | pageNo = pageNo <= 1 ? 0 : pageNo - 1; 26 | Pageable pageable = PageRequest.of(pageNo, properties.pageSize(), sort); 27 | Page productsPage = productRepository.findAll(pageable).map(ProductMapper::toProduct); 28 | 29 | return new PagedResult<>( 30 | productsPage.getContent(), 31 | productsPage.getTotalElements(), 32 | productsPage.getNumber() + 1, 33 | productsPage.getTotalPages(), 34 | productsPage.isFirst(), 35 | productsPage.isLast(), 36 | productsPage.hasNext(), 37 | productsPage.hasPrevious()); 38 | } 39 | 40 | public Optional getProductByCode(String code) { 41 | return productRepository.findByCode(code).map(ProductMapper::toProduct); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/web/controllers/ProductController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.web.controllers; 2 | 3 | import com.sivalabs.bookstore.catalog.domain.PagedResult; 4 | import com.sivalabs.bookstore.catalog.domain.Product; 5 | import com.sivalabs.bookstore.catalog.domain.ProductNotFoundException; 6 | import com.sivalabs.bookstore.catalog.domain.ProductService; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.PathVariable; 12 | import org.springframework.web.bind.annotation.RequestMapping; 13 | import org.springframework.web.bind.annotation.RequestParam; 14 | import org.springframework.web.bind.annotation.RestController; 15 | 16 | @RestController 17 | @RequestMapping("/api/products") 18 | class ProductController { 19 | private static final Logger log = LoggerFactory.getLogger(ProductController.class); 20 | private final ProductService productService; 21 | 22 | ProductController(ProductService productService) { 23 | this.productService = productService; 24 | } 25 | 26 | @GetMapping 27 | PagedResult getProducts(@RequestParam(name = "page", defaultValue = "1") int pageNo) { 28 | log.info("Fetching products for page: {}", pageNo); 29 | return productService.getProducts(pageNo); 30 | } 31 | 32 | @GetMapping("/{code}") 33 | ResponseEntity getProductByCode(@PathVariable String code) { 34 | log.info("Fetching product for code: {}", code); 35 | return productService 36 | .getProductByCode(code) 37 | .map(ResponseEntity::ok) 38 | .orElseThrow(() -> ProductNotFoundException.forCode(code)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /catalog-service/src/main/java/com/sivalabs/bookstore/catalog/web/exception/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.web.exception; 2 | 3 | import com.sivalabs.bookstore.catalog.domain.ProductNotFoundException; 4 | import java.net.URI; 5 | import java.time.Instant; 6 | import org.springframework.http.HttpStatus; 7 | import org.springframework.http.ProblemDetail; 8 | import org.springframework.web.bind.annotation.ExceptionHandler; 9 | import org.springframework.web.bind.annotation.RestControllerAdvice; 10 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 11 | 12 | @RestControllerAdvice 13 | class GlobalExceptionHandler extends ResponseEntityExceptionHandler { 14 | private static final URI NOT_FOUND_TYPE = URI.create("https://api.bookstore.com/errors/not-found"); 15 | private static final URI ISE_FOUND_TYPE = URI.create("https://api.bookstore.com/errors/server-error"); 16 | private static final String SERVICE_NAME = "catalog-service"; 17 | 18 | @ExceptionHandler(Exception.class) 19 | ProblemDetail handleUnhandledException(Exception e) { 20 | ProblemDetail problemDetail = 21 | ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); 22 | problemDetail.setTitle("Internal Server Error"); 23 | problemDetail.setType(ISE_FOUND_TYPE); 24 | problemDetail.setProperty("service", SERVICE_NAME); 25 | problemDetail.setProperty("error_category", "Generic"); 26 | problemDetail.setProperty("timestamp", Instant.now()); 27 | return problemDetail; 28 | } 29 | 30 | @ExceptionHandler(ProductNotFoundException.class) 31 | ProblemDetail handleProductNotFoundException(ProductNotFoundException e) { 32 | ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); 33 | problemDetail.setTitle("Product Not Found"); 34 | problemDetail.setType(NOT_FOUND_TYPE); 35 | problemDetail.setProperty("service", SERVICE_NAME); 36 | problemDetail.setProperty("error_category", "Generic"); 37 | problemDetail.setProperty("timestamp", Instant.now()); 38 | return problemDetail; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /catalog-service/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=catalog-service 2 | server.port=8081 3 | server.shutdown=graceful 4 | 5 | ######## Catalog Service Configuration ######### 6 | catalog.page-size=10 7 | 8 | ######## Actuator Configuration ######### 9 | management.info.git.mode=full 10 | management.endpoints.web.exposure.include=* 11 | management.metrics.tags.application=${spring.application.name} 12 | management.tracing.enabled=false 13 | management.tracing.sampling.probability=1.0 14 | 15 | ######## Swagger Configuration ######### 16 | swagger.api-gateway-url=http://localhost:8989/catalog 17 | 18 | ######## Database Configuration ######### 19 | spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:15432/postgres} 20 | spring.datasource.username=${DB_USERNAME:postgres} 21 | spring.datasource.password=${DB_PASSWORD:postgres} 22 | spring.jpa.open-in-view=false 23 | -------------------------------------------------------------------------------- /catalog-service/src/main/resources/db/migration/V1__create_products_table.sql: -------------------------------------------------------------------------------- 1 | create sequence product_id_seq start with 1 increment by 50; 2 | 3 | create table products 4 | ( 5 | id bigint default nextval('product_id_seq') not null, 6 | code text not null unique, 7 | name text not null, 8 | description text, 9 | image_url text, 10 | price numeric not null, 11 | primary key (id) 12 | ); 13 | -------------------------------------------------------------------------------- /catalog-service/src/main/resources/db/migration/V2__add_books_data.sql: -------------------------------------------------------------------------------- 1 | insert into products(code, name, description, image_url, price) values 2 | ('P100','The Hunger Games','Winning will make you famous. Losing means certain death...','https://images.gr-assets.com/books/1447303603l/2767052.jpg', 34.0), 3 | ('P101','To Kill a Mockingbird','The unforgettable novel of a childhood in a sleepy Southern town and the crisis of conscience that rocked it...','https://images.gr-assets.com/books/1361975680l/2657.jpg', 45.40), 4 | ('P102','The Chronicles of Narnia','Journeys to the end of the world, fantastic creatures, and epic battles between good and evil—what more could any reader ask for in one book?...','https://images.gr-assets.com/books/1449868701l/11127.jpg', 44.50), 5 | ('P103','Gone with the Wind', 'Gone with the Wind is a novel written by Margaret Mitchell, first published in 1936.', 'https://images.gr-assets.com/books/1328025229l/18405.jpg',44.50), 6 | ('P104','The Fault in Our Stars','Despite the tumor-shrinking medical miracle that has bought her a few years, Hazel has never been anything but terminal, her final chapter inscribed upon diagnosis.','https://images.gr-assets.com/books/1360206420l/11870085.jpg',14.50), 7 | ('P105','The Giving Tree','Once there was a tree...and she loved a little boy.','https://images.gr-assets.com/books/1174210942l/370493.jpg',32.0), 8 | ('P106','The Da Vinci Code','An ingenious code hidden in the works of Leonardo da Vinci.A desperate race through the cathedrals and castles of Europe','https://images.gr-assets.com/books/1303252999l/968.jpg',14.50), 9 | ('P107','The Alchemist','Paulo Coelho''s masterpiece tells the mystical story of Santiago, an Andalusian shepherd boy who yearns to travel in search of a worldly treasure','https://images.gr-assets.com/books/1483412266l/865.jpg',12.0), 10 | ('P108','Charlotte''s Web','This beloved book by E. B. White, author of Stuart Little and The Trumpet of the Swan, is a classic of children''s literature','https://images.gr-assets.com/books/1439632243l/24178.jpg',14.0), 11 | ('P109','The Little Prince','Moral allegory and spiritual autobiography, The Little Prince is the most translated book in the French language.','https://images.gr-assets.com/books/1367545443l/157993.jpg',16.50), 12 | ('P110','A Thousand Splendid Suns','A Thousand Splendid Suns is a breathtaking story set against the volatile events of Afghanistan''s last thirty years—from the Soviet invasion to the reign of the Taliban to post-Taliban rebuilding—that puts the violence, fear, hope, and faith of this country in intimate, human terms.','https://images.gr-assets.com/books/1345958969l/128029.jpg',15.50), 13 | ('P111','A Game of Thrones','Here is the first volume in George R. R. Martin’s magnificent cycle of novels that includes A Clash of Kings and A Storm of Swords.','https://images.gr-assets.com/books/1436732693l/13496.jpg',32.0), 14 | ('P112','The Book Thief','Nazi Germany. The country is holding its breath. Death has never been busier, and will be busier still.By her brother''s graveside, Liesel''s life is changed when she picks up a single object, partially hidden in the snow.','https://images.gr-assets.com/books/1522157426l/19063.jpg',30.0), 15 | ('P113','One Flew Over the Cuckoo''s Nest','Tyrannical Nurse Ratched rules her ward in an Oregon State mental hospital with a strict and unbending routine, unopposed by her patients, who remain cowed by mind-numbing medication and the threat of electric shock therapy.','https://images.gr-assets.com/books/1516211014l/332613.jpg',23.0), 16 | ('P114','Fifty Shades of Grey','When literature student Anastasia Steele goes to interview young entrepreneur Christian Grey, she encounters a man who is beautiful, brilliant, and intimidating.','https://images.gr-assets.com/books/1385207843l/10818853.jpg', 27.0) 17 | ; -------------------------------------------------------------------------------- /catalog-service/src/test/java/com/sivalabs/bookstore/catalog/AbstractIT.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog; 2 | 3 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 4 | 5 | import io.restassured.RestAssured; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | import org.springframework.boot.test.web.server.LocalServerPort; 9 | import org.springframework.context.annotation.Import; 10 | 11 | @SpringBootTest(webEnvironment = RANDOM_PORT) 12 | @Import(ContainersConfig.class) 13 | public abstract class AbstractIT { 14 | @LocalServerPort 15 | int port; 16 | 17 | @BeforeEach 18 | void setUp() { 19 | RestAssured.port = port; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /catalog-service/src/test/java/com/sivalabs/bookstore/catalog/CatalogServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | class CatalogServiceApplicationTests extends AbstractIT { 6 | 7 | @Test 8 | void contextLoads() {} 9 | } 10 | -------------------------------------------------------------------------------- /catalog-service/src/test/java/com/sivalabs/bookstore/catalog/ContainersConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog; 2 | 3 | import org.springframework.boot.test.context.TestConfiguration; 4 | import org.springframework.boot.testcontainers.service.connection.ServiceConnection; 5 | import org.springframework.context.annotation.Bean; 6 | import org.testcontainers.containers.PostgreSQLContainer; 7 | import org.testcontainers.utility.DockerImageName; 8 | 9 | @TestConfiguration(proxyBeanMethods = false) 10 | public class ContainersConfig { 11 | @Bean 12 | @ServiceConnection 13 | PostgreSQLContainer postgresContainer() { 14 | return new PostgreSQLContainer<>(DockerImageName.parse("postgres:17-alpine")); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /catalog-service/src/test/java/com/sivalabs/bookstore/catalog/TestCatalogServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | 5 | public class TestCatalogServiceApplication { 6 | 7 | public static void main(String[] args) { 8 | SpringApplication.from(CatalogServiceApplication::main) 9 | .with(ContainersConfig.class) 10 | .run(args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /catalog-service/src/test/java/com/sivalabs/bookstore/catalog/domain/ProductRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.math.BigDecimal; 6 | import java.util.List; 7 | import org.junit.jupiter.api.Test; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 10 | import org.springframework.test.context.jdbc.Sql; 11 | 12 | @DataJpaTest( 13 | properties = { 14 | "spring.test.database.replace=none", 15 | "spring.datasource.url=jdbc:tc:postgresql:16-alpine:///db", 16 | }) 17 | // @Import(ContainersConfig.class) 18 | @Sql("/test-data.sql") 19 | class ProductRepositoryTest { 20 | 21 | @Autowired 22 | private ProductRepository productRepository; 23 | 24 | // You don't need to test the methods provided by Spring Data JPA. 25 | // This test is to demonstrate how to write tests for the repository layer. 26 | @Test 27 | void shouldGetAllProducts() { 28 | List products = productRepository.findAll(); 29 | assertThat(products).hasSize(15); 30 | } 31 | 32 | @Test 33 | void shouldGetProductByCode() { 34 | ProductEntity product = productRepository.findByCode("P100").orElseThrow(); 35 | assertThat(product.getCode()).isEqualTo("P100"); 36 | assertThat(product.getName()).isEqualTo("The Hunger Games"); 37 | assertThat(product.getDescription()).isEqualTo("Winning will make you famous. Losing means certain death..."); 38 | assertThat(product.getPrice()).isEqualTo(new BigDecimal("34.0")); 39 | } 40 | 41 | @Test 42 | void shouldReturnEmptyWhenProductCodeNotExists() { 43 | assertThat(productRepository.findByCode("invalid_product_code")).isEmpty(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /catalog-service/src/test/java/com/sivalabs/bookstore/catalog/web/controllers/ProductControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.web.controllers; 2 | 3 | import static io.restassured.RestAssured.given; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | import static org.hamcrest.Matchers.hasSize; 6 | import static org.hamcrest.Matchers.is; 7 | 8 | import com.sivalabs.bookstore.catalog.AbstractIT; 9 | import com.sivalabs.bookstore.catalog.domain.Product; 10 | import io.restassured.http.ContentType; 11 | import java.math.BigDecimal; 12 | import org.junit.jupiter.api.Test; 13 | import org.springframework.test.context.jdbc.Sql; 14 | 15 | @Sql("/test-data.sql") 16 | class ProductControllerTest extends AbstractIT { 17 | 18 | @Test 19 | void shouldReturnProducts() { 20 | given().contentType(ContentType.JSON) 21 | .when() 22 | .get("/api/products") 23 | .then() 24 | .statusCode(200) 25 | .body("data", hasSize(10)) 26 | .body("totalElements", is(15)) 27 | .body("pageNumber", is(1)) 28 | .body("totalPages", is(2)) 29 | .body("isFirst", is(true)) 30 | .body("isLast", is(false)) 31 | .body("hasNext", is(true)) 32 | .body("hasPrevious", is(false)); 33 | } 34 | 35 | @Test 36 | void shouldGetProductByCode() { 37 | Product product = given().contentType(ContentType.JSON) 38 | .when() 39 | .get("/api/products/{code}", "P100") 40 | .then() 41 | .statusCode(200) 42 | .assertThat() 43 | .extract() 44 | .body() 45 | .as(Product.class); 46 | 47 | assertThat(product.code()).isEqualTo("P100"); 48 | assertThat(product.name()).isEqualTo("The Hunger Games"); 49 | assertThat(product.description()).isEqualTo("Winning will make you famous. Losing means certain death..."); 50 | assertThat(product.price()).isEqualTo(new BigDecimal("34.0")); 51 | } 52 | 53 | @Test 54 | void shouldReturnNotFoundWhenProductCodeNotExists() { 55 | String code = "invalid_product_code"; 56 | given().contentType(ContentType.JSON) 57 | .when() 58 | .get("/api/products/{code}", code) 59 | .then() 60 | .statusCode(404) 61 | .body("status", is(404)) 62 | .body("title", is("Product Not Found")) 63 | .body("detail", is("Product with code " + code + " not found")); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /catalog-service/src/test/resources/test-data.sql: -------------------------------------------------------------------------------- 1 | truncate table products; 2 | 3 | insert into products(code, name, description, image_url, price) values 4 | ('P100','The Hunger Games','Winning will make you famous. Losing means certain death...','https://images.gr-assets.com/books/1447303603l/2767052.jpg', 34.0), 5 | ('P101','To Kill a Mockingbird','The unforgettable novel of a childhood in a sleepy Southern town and the crisis of conscience that rocked it...','https://images.gr-assets.com/books/1361975680l/2657.jpg', 45.40), 6 | ('P102','The Chronicles of Narnia','Journeys to the end of the world, fantastic creatures, and epic battles between good and evil—what more could any reader ask for in one book?...','https://images.gr-assets.com/books/1449868701l/11127.jpg', 44.50), 7 | ('P103','Gone with the Wind', 'Gone with the Wind is a novel written by Margaret Mitchell, first published in 1936.', 'https://images.gr-assets.com/books/1328025229l/18405.jpg',44.50), 8 | ('P104','The Fault in Our Stars','Despite the tumor-shrinking medical miracle that has bought her a few years, Hazel has never been anything but terminal, her final chapter inscribed upon diagnosis.','https://images.gr-assets.com/books/1360206420l/11870085.jpg',14.50), 9 | ('P105','The Giving Tree','Once there was a tree...and she loved a little boy.','https://images.gr-assets.com/books/1174210942l/370493.jpg',32.0), 10 | ('P106','The Da Vinci Code','An ingenious code hidden in the works of Leonardo da Vinci.A desperate race through the cathedrals and castles of Europe','https://images.gr-assets.com/books/1303252999l/968.jpg',14.50), 11 | ('P107','The Alchemist','Paulo Coelho''s masterpiece tells the mystical story of Santiago, an Andalusian shepherd boy who yearns to travel in search of a worldly treasure','https://images.gr-assets.com/books/1483412266l/865.jpg',12.0), 12 | ('P108','Charlotte''s Web','This beloved book by E. B. White, author of Stuart Little and The Trumpet of the Swan, is a classic of children''s literature','https://images.gr-assets.com/books/1439632243l/24178.jpg',14.0), 13 | ('P109','The Little Prince','Moral allegory and spiritual autobiography, The Little Prince is the most translated book in the French language.','https://images.gr-assets.com/books/1367545443l/157993.jpg',16.50), 14 | ('P110','A Thousand Splendid Suns','A Thousand Splendid Suns is a breathtaking story set against the volatile events of Afghanistan''s last thirty years—from the Soviet invasion to the reign of the Taliban to post-Taliban rebuilding—that puts the violence, fear, hope, and faith of this country in intimate, human terms.','https://images.gr-assets.com/books/1345958969l/128029.jpg',15.50), 15 | ('P111','A Game of Thrones','Here is the first volume in George R. R. Martin’s magnificent cycle of novels that includes A Clash of Kings and A Storm of Swords.','https://images.gr-assets.com/books/1436732693l/13496.jpg',32.0), 16 | ('P112','The Book Thief','Nazi Germany. The country is holding its breath. Death has never been busier, and will be busier still.By her brother''s graveside, Liesel''s life is changed when she picks up a single object, partially hidden in the snow.','https://images.gr-assets.com/books/1522157426l/19063.jpg',30.0), 17 | ('P113','One Flew Over the Cuckoo''s Nest','Tyrannical Nurse Ratched rules her ward in an Oregon State mental hospital with a strict and unbending routine, unopposed by her patients, who remain cowed by mind-numbing medication and the threat of electric shock therapy.','https://images.gr-assets.com/books/1516211014l/332613.jpg',23.0), 18 | ('P114','Fifty Shades of Grey','When literature student Anastasia Steele goes to interview young entrepreneur Christian Grey, she encounters a man who is beautiful, brilliant, and intimidating.','https://images.gr-assets.com/books/1385207843l/10818853.jpg', 27.0) 19 | ; -------------------------------------------------------------------------------- /deployment/docker-compose/apps.yml: -------------------------------------------------------------------------------- 1 | name: 'spring-boot-microservices-course' 2 | services: 3 | catalog-service: 4 | image: sivaprasadreddy/bookstore-catalog-service 5 | container_name: catalog-service 6 | environment: 7 | - SPRING_PROFILES_ACTIVE=docker 8 | - DB_URL=jdbc:postgresql://catalog-db:5432/postgres 9 | - DB_USERNAME=postgres 10 | - DB_PASSWORD=postgres 11 | - SWAGGER_API_GATEWAY_URL=http://api-gateway:8989/catalog 12 | - MANAGEMENT_TRACING_ENABLED=true 13 | - MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://tempo:9411 14 | ports: 15 | - "8081:8081" 16 | restart: unless-stopped 17 | depends_on: 18 | catalog-db: 19 | condition: service_healthy 20 | deploy: 21 | resources: 22 | limits: 23 | memory: 700m 24 | labels: 25 | logging: "promtail" 26 | 27 | order-service: 28 | image: sivaprasadreddy/bookstore-order-service 29 | container_name: order-service 30 | environment: 31 | - SPRING_PROFILES_ACTIVE=docker 32 | - ORDERS_CATALOG_SERVICE_URL=http://api-gateway:8989/catalog 33 | - DB_URL=jdbc:postgresql://orders-db:5432/postgres 34 | - DB_USERNAME=postgres 35 | - DB_PASSWORD=postgres 36 | - RABBITMQ_HOST=bookstore-rabbitmq 37 | - RABBITMQ_PORT=5672 38 | - RABBITMQ_USERNAME=guest 39 | - RABBITMQ_PASSWORD=guest 40 | - OAUTH2_SERVER_URL=http://keycloak:9191 41 | - SWAGGER_API_GATEWAY_URL=http://api-gateway:8989/orders 42 | - MANAGEMENT_TRACING_ENABLED=true 43 | - MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://tempo:9411 44 | ports: 45 | - "8082:8082" 46 | restart: unless-stopped 47 | depends_on: 48 | orders-db: 49 | condition: service_healthy 50 | bookstore-rabbitmq: 51 | condition: service_healthy 52 | deploy: 53 | resources: 54 | limits: 55 | memory: 700m 56 | labels: 57 | logging: "promtail" 58 | 59 | notification-service: 60 | image: sivaprasadreddy/bookstore-notification-service 61 | container_name: notification-service 62 | environment: 63 | - SPRING_PROFILES_ACTIVE=docker 64 | - DB_URL=jdbc:postgresql://notifications-db:5432/postgres 65 | - DB_USERNAME=postgres 66 | - DB_PASSWORD=postgres 67 | - RABBITMQ_HOST=bookstore-rabbitmq 68 | - RABBITMQ_PORT=5672 69 | - RABBITMQ_USERNAME=guest 70 | - RABBITMQ_PASSWORD=guest 71 | - MAIL_HOST=mailhog 72 | - MAIL_PORT=1025 73 | - MANAGEMENT_TRACING_ENABLED=true 74 | - MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://tempo:9411 75 | ports: 76 | - "8083:8083" 77 | restart: unless-stopped 78 | depends_on: 79 | notifications-db: 80 | condition: service_healthy 81 | bookstore-rabbitmq: 82 | condition: service_healthy 83 | mailhog: 84 | condition: service_started 85 | deploy: 86 | resources: 87 | limits: 88 | memory: 700m 89 | labels: 90 | logging: "promtail" 91 | 92 | api-gateway: 93 | image: sivaprasadreddy/bookstore-api-gateway 94 | container_name: api-gateway 95 | environment: 96 | - SPRING_PROFILES_ACTIVE=docker 97 | - CATALOG_SERVICE_URL=http://catalog-service:8081 98 | - ORDER_SERVICE_URL=http://order-service:8082 99 | - MANAGEMENT_TRACING_ENABLED=true 100 | - MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://tempo:9411 101 | ports: 102 | - "8989:8989" 103 | restart: unless-stopped 104 | deploy: 105 | resources: 106 | limits: 107 | memory: 700m 108 | labels: 109 | logging: "promtail" 110 | 111 | bookstore-webapp: 112 | image: sivaprasadreddy/bookstore-webapp 113 | container_name: bookstore-webapp 114 | environment: 115 | - SPRING_PROFILES_ACTIVE=docker 116 | - BOOKSTORE_API_GATEWAY_URL=http://api-gateway:8989 117 | - OAUTH2_SERVER_URL=http://keycloak:9191 118 | - MANAGEMENT_TRACING_ENABLED=true 119 | - MANAGEMENT_ZIPKIN_TRACING_ENDPOINT=http://tempo:9411 120 | ports: 121 | - "8080:8080" 122 | restart: unless-stopped 123 | deploy: 124 | resources: 125 | limits: 126 | memory: 700m 127 | labels: 128 | logging: "promtail" -------------------------------------------------------------------------------- /deployment/docker-compose/infra.yml: -------------------------------------------------------------------------------- 1 | name: 'spring-boot-microservices-course' 2 | services: 3 | catalog-db: 4 | image: postgres:17-alpine 5 | container_name: catalog-db 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | - POSTGRES_DB=postgres 10 | ports: 11 | - "15432:5432" 12 | healthcheck: 13 | test: [ "CMD-SHELL", "pg_isready -U postgres" ] 14 | interval: 10s 15 | timeout: 5s 16 | retries: 5 17 | deploy: 18 | resources: 19 | limits: 20 | memory: 500m 21 | 22 | orders-db: 23 | image: postgres:17-alpine 24 | container_name: orders-db 25 | environment: 26 | - POSTGRES_USER=postgres 27 | - POSTGRES_PASSWORD=postgres 28 | - POSTGRES_DB=postgres 29 | ports: 30 | - "25432:5432" 31 | healthcheck: 32 | test: [ "CMD-SHELL", "pg_isready -U postgres" ] 33 | interval: 10s 34 | timeout: 5s 35 | retries: 5 36 | deploy: 37 | resources: 38 | limits: 39 | memory: 500m 40 | 41 | bookstore-rabbitmq: 42 | image: rabbitmq:4.0.4-management 43 | container_name: bookstore-rabbitmq 44 | environment: 45 | - RABBITMQ_DEFAULT_USER=guest 46 | - RABBITMQ_DEFAULT_PASS=guest 47 | ports: 48 | - "5672:5672" 49 | - "15672:15672" 50 | healthcheck: 51 | test: rabbitmq-diagnostics check_port_connectivity 52 | interval: 30s 53 | timeout: 30s 54 | retries: 10 55 | deploy: 56 | resources: 57 | limits: 58 | memory: 500m 59 | 60 | notifications-db: 61 | image: postgres:17-alpine 62 | container_name: notifications-db 63 | environment: 64 | - POSTGRES_USER=postgres 65 | - POSTGRES_PASSWORD=postgres 66 | - POSTGRES_DB=postgres 67 | ports: 68 | - "35432:5432" 69 | healthcheck: 70 | test: [ "CMD-SHELL", "pg_isready -U postgres" ] 71 | interval: 10s 72 | timeout: 5s 73 | retries: 5 74 | deploy: 75 | resources: 76 | limits: 77 | memory: 500m 78 | 79 | mailhog: 80 | image: mailhog/mailhog:v1.0.1 81 | container_name: mailhog 82 | ports: 83 | - "1025:1025" 84 | - "8025:8025" 85 | 86 | keycloak: 87 | image: quay.io/keycloak/keycloak:24.0.2 88 | command: [ 'start-dev', '--import-realm', '--http-port=9191' ] 89 | container_name: keycloak 90 | hostname: keycloak 91 | volumes: 92 | - ./realm-config:/opt/keycloak/data/import 93 | environment: 94 | - KEYCLOAK_ADMIN=admin 95 | - KEYCLOAK_ADMIN_PASSWORD=admin1234 96 | ports: 97 | - "9191:9191" 98 | deploy: 99 | resources: 100 | limits: 101 | memory: 2gb -------------------------------------------------------------------------------- /deployment/docker-compose/monitoring.yml: -------------------------------------------------------------------------------- 1 | name: 'spring-boot-microservices-course' 2 | services: 3 | prometheus: 4 | image: prom/prometheus:v2.51.2 5 | container_name: prometheus 6 | ports: 7 | - "9090:9090" 8 | volumes: 9 | - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml 10 | command: "--config.file=/etc/prometheus/prometheus.yml" 11 | 12 | promtail: 13 | image: grafana/promtail:3.0.0 14 | container_name: promtail 15 | volumes: 16 | - ./promtail/promtail-docker-config.yml:/etc/promtail/docker-config.yml 17 | - /var/lib/docker/containers:/var/lib/docker/containers:ro 18 | - /var/run/docker.sock:/var/run/docker.sock 19 | command: "--config.file=/etc/promtail/docker-config.yml" 20 | 21 | loki: 22 | image: grafana/loki:3.0.0 23 | container_name: loki 24 | command: "-config.file=/etc/loki/local-config.yaml" 25 | ports: 26 | - "3100:3100" 27 | depends_on: 28 | - promtail 29 | 30 | tempo: 31 | image: grafana/tempo:2.4.1 32 | container_name: tempo 33 | command: "-config.file /etc/tempo-config.yml" 34 | ports: 35 | - "3200:3200" # Tempo 36 | - "9411:9411" # Zipkin 37 | volumes: 38 | - ./tempo/tempo.yml:/etc/tempo-config.yml 39 | 40 | grafana: 41 | image: grafana/grafana:10.4.2 42 | container_name: grafana 43 | ports: 44 | - "3000:3000" 45 | volumes: 46 | - grafana_data:/var/lib/grafana 47 | environment: 48 | - GF_SECURITY_ADMIN_USER=admin 49 | - GF_SECURITY_ADMIN_PASSWORD=admin123 50 | - GF_USERS_ALLOW_SIGN_UP=false 51 | 52 | volumes: 53 | grafana_data: {} 54 | -------------------------------------------------------------------------------- /deployment/docker-compose/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | # global config 2 | global: 3 | scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute. 4 | 5 | scrape_configs: 6 | # The job name is added as a label `job=` to any timeseries scraped from this config. 7 | - job_name: 'prometheus' 8 | # metrics_path defaults to '/metrics' 9 | # scheme defaults to 'http'. 10 | static_configs: 11 | - targets: ['localhost:9090'] 12 | 13 | - job_name: 'catalog-service' 14 | metrics_path: '/actuator/prometheus' 15 | scrape_interval: 5s 16 | static_configs: 17 | - targets: ['catalog-service:8081'] 18 | 19 | - job_name: 'order-service' 20 | metrics_path: '/actuator/prometheus' 21 | scrape_interval: 5s 22 | static_configs: 23 | - targets: ['order-service:8082'] 24 | 25 | - job_name: 'notification-service' 26 | metrics_path: '/actuator/prometheus' 27 | scrape_interval: 5s 28 | static_configs: 29 | - targets: ['notification-service:8083'] 30 | 31 | - job_name: 'api-gateway' 32 | metrics_path: '/actuator/prometheus' 33 | scrape_interval: 5s 34 | static_configs: 35 | - targets: ['api-gateway:8989'] 36 | 37 | - job_name: 'bookstore-webapp' 38 | metrics_path: '/actuator/prometheus' 39 | scrape_interval: 5s 40 | static_configs: 41 | - targets: ['bookstore-webapp:8080'] 42 | -------------------------------------------------------------------------------- /deployment/docker-compose/promtail/promtail-docker-config.yml: -------------------------------------------------------------------------------- 1 | server: 2 | http_listen_port: 9080 3 | grpc_listen_port: 0 4 | 5 | positions: 6 | filename: /tmp/positions.yaml 7 | 8 | clients: 9 | - url: http://loki:3100/loki/api/v1/push 10 | 11 | scrape_configs: 12 | - job_name: flog_scrape 13 | docker_sd_configs: 14 | - host: unix:///var/run/docker.sock 15 | refresh_interval: 5s 16 | filters: 17 | - name: label 18 | values: ["logging=promtail"] 19 | relabel_configs: 20 | - source_labels: ['__meta_docker_container_name'] 21 | regex: '/(.*)' 22 | target_label: 'container' 23 | -------------------------------------------------------------------------------- /deployment/docker-compose/tempo/tempo.yml: -------------------------------------------------------------------------------- 1 | server: 2 | http_listen_port: 3200 3 | 4 | distributor: 5 | receivers: 6 | otlp: 7 | protocols: 8 | grpc: 9 | http: 10 | zipkin: 11 | 12 | ingester: 13 | trace_idle_period: 10s 14 | max_block_bytes: 1_000_000 15 | max_block_duration: 5m 16 | 17 | compactor: 18 | compaction: 19 | compaction_window: 1h 20 | max_compaction_objects: 1000000 21 | block_retention: 1h 22 | compacted_block_retention: 10m 23 | 24 | storage: 25 | trace: 26 | backend: local 27 | local: 28 | path: /tmp/tempo/blocks 29 | pool: 30 | max_workers: 100 31 | queue_depth: 10000 32 | -------------------------------------------------------------------------------- /docs/bookstore-spring-microservices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/docs/bookstore-spring-microservices.png -------------------------------------------------------------------------------- /docs/spring-microservices-course.slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/docs/spring-microservices-course.slides.pdf -------------------------------------------------------------------------------- /docs/youtube-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/docs/youtube-thumbnail.png -------------------------------------------------------------------------------- /notification-service/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /notification-service/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/notification-service/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /notification-service/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/ApplicationProperties.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | 5 | @ConfigurationProperties(prefix = "notification") 6 | public record ApplicationProperties( 7 | String orderEventsExchange, 8 | String newOrdersQueue, 9 | String deliveredOrdersQueue, 10 | String cancelledOrdersQueue, 11 | String errorOrdersQueue, 12 | String supportEmail) {} 13 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/NotificationServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | 7 | @SpringBootApplication 8 | @ConfigurationPropertiesScan 9 | public class NotificationServiceApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(NotificationServiceApplication.class, args); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/config/RabbitMQConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.config; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.sivalabs.bookstore.notifications.ApplicationProperties; 5 | import org.springframework.amqp.core.Binding; 6 | import org.springframework.amqp.core.BindingBuilder; 7 | import org.springframework.amqp.core.DirectExchange; 8 | import org.springframework.amqp.core.Queue; 9 | import org.springframework.amqp.core.QueueBuilder; 10 | import org.springframework.amqp.rabbit.connection.ConnectionFactory; 11 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 12 | import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; 13 | import org.springframework.context.annotation.Bean; 14 | import org.springframework.context.annotation.Configuration; 15 | 16 | @Configuration 17 | class RabbitMQConfig { 18 | private final ApplicationProperties properties; 19 | 20 | RabbitMQConfig(ApplicationProperties properties) { 21 | this.properties = properties; 22 | } 23 | 24 | @Bean 25 | DirectExchange exchange() { 26 | return new DirectExchange(properties.orderEventsExchange()); 27 | } 28 | 29 | @Bean 30 | Queue newOrdersQueue() { 31 | return QueueBuilder.durable(properties.newOrdersQueue()).build(); 32 | } 33 | 34 | @Bean 35 | Binding newOrdersQueueBinding() { 36 | return BindingBuilder.bind(newOrdersQueue()).to(exchange()).with(properties.newOrdersQueue()); 37 | } 38 | 39 | @Bean 40 | Queue deliveredOrdersQueue() { 41 | return QueueBuilder.durable(properties.deliveredOrdersQueue()).build(); 42 | } 43 | 44 | @Bean 45 | Binding deliveredOrdersQueueBinding() { 46 | return BindingBuilder.bind(deliveredOrdersQueue()).to(exchange()).with(properties.deliveredOrdersQueue()); 47 | } 48 | 49 | @Bean 50 | Queue cancelledOrdersQueue() { 51 | return QueueBuilder.durable(properties.cancelledOrdersQueue()).build(); 52 | } 53 | 54 | @Bean 55 | Binding cancelledOrdersQueueBinding() { 56 | return BindingBuilder.bind(cancelledOrdersQueue()).to(exchange()).with(properties.cancelledOrdersQueue()); 57 | } 58 | 59 | @Bean 60 | Queue errorOrdersQueue() { 61 | return QueueBuilder.durable(properties.errorOrdersQueue()).build(); 62 | } 63 | 64 | @Bean 65 | Binding errorOrdersQueueBinding() { 66 | return BindingBuilder.bind(errorOrdersQueue()).to(exchange()).with(properties.errorOrdersQueue()); 67 | } 68 | 69 | @Bean 70 | public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, ObjectMapper objectMapper) { 71 | final var rabbitTemplate = new RabbitTemplate(connectionFactory); 72 | rabbitTemplate.setMessageConverter(jacksonConverter(objectMapper)); 73 | return rabbitTemplate; 74 | } 75 | 76 | @Bean 77 | public Jackson2JsonMessageConverter jacksonConverter(ObjectMapper mapper) { 78 | return new Jackson2JsonMessageConverter(mapper); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/domain/OrderEventEntity.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.domain; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.GeneratedValue; 6 | import jakarta.persistence.GenerationType; 7 | import jakarta.persistence.Id; 8 | import jakarta.persistence.SequenceGenerator; 9 | import jakarta.persistence.Table; 10 | import java.time.LocalDateTime; 11 | 12 | @Entity 13 | @Table(name = "order_events") 14 | public class OrderEventEntity { 15 | @Id 16 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_event_id_generator") 17 | @SequenceGenerator(name = "order_event_id_generator", sequenceName = "order_event_id_seq") 18 | private Long id; 19 | 20 | @Column(nullable = false, unique = true) 21 | private String eventId; 22 | 23 | @Column(name = "created_at", nullable = false, updatable = false) 24 | private LocalDateTime createdAt = LocalDateTime.now(); 25 | 26 | @Column(name = "updated_at") 27 | private LocalDateTime updatedAt; 28 | 29 | public OrderEventEntity() {} 30 | 31 | public OrderEventEntity(String eventId) { 32 | this.eventId = eventId; 33 | } 34 | 35 | public Long getId() { 36 | return id; 37 | } 38 | 39 | public void setId(Long id) { 40 | this.id = id; 41 | } 42 | 43 | public String getEventId() { 44 | return eventId; 45 | } 46 | 47 | public void setEventId(String eventId) { 48 | this.eventId = eventId; 49 | } 50 | 51 | public LocalDateTime getCreatedAt() { 52 | return createdAt; 53 | } 54 | 55 | public void setCreatedAt(LocalDateTime createdAt) { 56 | this.createdAt = createdAt; 57 | } 58 | 59 | public LocalDateTime getUpdatedAt() { 60 | return updatedAt; 61 | } 62 | 63 | public void setUpdatedAt(LocalDateTime updatedAt) { 64 | this.updatedAt = updatedAt; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/domain/OrderEventRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.domain; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | public interface OrderEventRepository extends JpaRepository { 6 | boolean existsByEventId(String eventId); 7 | } 8 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/domain/models/Address.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.domain.models; 2 | 3 | public record Address( 4 | String addressLine1, String addressLine2, String city, String state, String zipCode, String country) {} 5 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/domain/models/Customer.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.domain.models; 2 | 3 | public record Customer(String name, String email, String phone) {} 4 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/domain/models/OrderCancelledEvent.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.Set; 5 | 6 | public record OrderCancelledEvent( 7 | String eventId, 8 | String orderNumber, 9 | Set items, 10 | Customer customer, 11 | Address deliveryAddress, 12 | String reason, 13 | LocalDateTime createdAt) {} 14 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/domain/models/OrderCreatedEvent.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.Set; 5 | 6 | public record OrderCreatedEvent( 7 | String eventId, 8 | String orderNumber, 9 | Set items, 10 | Customer customer, 11 | Address deliveryAddress, 12 | LocalDateTime createdAt) {} 13 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/domain/models/OrderDeliveredEvent.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.Set; 5 | 6 | public record OrderDeliveredEvent( 7 | String eventId, 8 | String orderNumber, 9 | Set items, 10 | Customer customer, 11 | Address deliveryAddress, 12 | LocalDateTime createdAt) {} 13 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/domain/models/OrderErrorEvent.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.Set; 5 | 6 | public record OrderErrorEvent( 7 | String eventId, 8 | String orderNumber, 9 | Set items, 10 | Customer customer, 11 | Address deliveryAddress, 12 | String reason, 13 | LocalDateTime createdAt) {} 14 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/domain/models/OrderItem.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.domain.models; 2 | 3 | import java.math.BigDecimal; 4 | 5 | public record OrderItem(String code, String name, BigDecimal price, Integer quantity) {} 6 | -------------------------------------------------------------------------------- /notification-service/src/main/java/com/sivalabs/bookstore/notifications/events/OrderEventHandler.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.events; 2 | 3 | import com.sivalabs.bookstore.notifications.domain.NotificationService; 4 | import com.sivalabs.bookstore.notifications.domain.OrderEventEntity; 5 | import com.sivalabs.bookstore.notifications.domain.OrderEventRepository; 6 | import com.sivalabs.bookstore.notifications.domain.models.OrderCancelledEvent; 7 | import com.sivalabs.bookstore.notifications.domain.models.OrderCreatedEvent; 8 | import com.sivalabs.bookstore.notifications.domain.models.OrderDeliveredEvent; 9 | import com.sivalabs.bookstore.notifications.domain.models.OrderErrorEvent; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | import org.springframework.amqp.rabbit.annotation.RabbitListener; 13 | import org.springframework.stereotype.Component; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | @Component 17 | @Transactional 18 | public class OrderEventHandler { 19 | private static final Logger log = LoggerFactory.getLogger(OrderEventHandler.class); 20 | 21 | private final NotificationService notificationService; 22 | private final OrderEventRepository orderEventRepository; 23 | 24 | public OrderEventHandler(NotificationService notificationService, OrderEventRepository orderEventRepository) { 25 | this.notificationService = notificationService; 26 | this.orderEventRepository = orderEventRepository; 27 | } 28 | 29 | @RabbitListener(queues = "${notification.new-orders-queue}") 30 | public void handle(OrderCreatedEvent event) { 31 | if (orderEventRepository.existsByEventId(event.eventId())) { 32 | log.warn("Received duplicate OrderCreatedEvent with eventId: {}", event.eventId()); 33 | return; 34 | } 35 | log.info("Received a OrderCreatedEvent with orderNumber:{}: ", event.orderNumber()); 36 | notificationService.sendOrderCreatedNotification(event); 37 | var orderEvent = new OrderEventEntity(event.eventId()); 38 | orderEventRepository.save(orderEvent); 39 | } 40 | 41 | @RabbitListener(queues = "${notification.delivered-orders-queue}") 42 | public void handle(OrderDeliveredEvent event) { 43 | if (orderEventRepository.existsByEventId(event.eventId())) { 44 | log.warn("Received duplicate OrderDeliveredEvent with eventId: {}", event.eventId()); 45 | return; 46 | } 47 | log.info("Received a OrderDeliveredEvent with orderNumber:{}: ", event.orderNumber()); 48 | notificationService.sendOrderDeliveredNotification(event); 49 | var orderEvent = new OrderEventEntity(event.eventId()); 50 | orderEventRepository.save(orderEvent); 51 | } 52 | 53 | @RabbitListener(queues = "${notification.cancelled-orders-queue}") 54 | public void handle(OrderCancelledEvent event) { 55 | if (orderEventRepository.existsByEventId(event.eventId())) { 56 | log.warn("Received duplicate OrderCancelledEvent with eventId: {}", event.eventId()); 57 | return; 58 | } 59 | notificationService.sendOrderCancelledNotification(event); 60 | log.info("Received a OrderCancelledEvent with orderNumber:{}: ", event.orderNumber()); 61 | var orderEvent = new OrderEventEntity(event.eventId()); 62 | orderEventRepository.save(orderEvent); 63 | } 64 | 65 | @RabbitListener(queues = "${notification.error-orders-queue}") 66 | public void handle(OrderErrorEvent event) { 67 | if (orderEventRepository.existsByEventId(event.eventId())) { 68 | log.warn("Received duplicate OrderErrorEvent with eventId: {}", event.eventId()); 69 | return; 70 | } 71 | log.info("Received a OrderErrorEvent with orderNumber:{}: ", event.orderNumber()); 72 | notificationService.sendOrderErrorEventNotification(event); 73 | OrderEventEntity orderEvent = new OrderEventEntity(event.eventId()); 74 | orderEventRepository.save(orderEvent); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=notification-service 2 | server.port=8083 3 | server.shutdown=graceful 4 | 5 | ######## Notification Service Configuration ######### 6 | notification.order-events-exchange=orders-exchange 7 | notification.new-orders-queue=new-orders 8 | notification.delivered-orders-queue=delivered-orders 9 | notification.cancelled-orders-queue=cancelled-orders 10 | notification.error-orders-queue=error-orders 11 | notification.support-email=siva@sivalabs.com 12 | 13 | ######## Actuator Configuration ######### 14 | management.info.git.mode=full 15 | management.endpoints.web.exposure.include=* 16 | management.metrics.tags.application=${spring.application.name} 17 | management.tracing.enabled=false 18 | management.tracing.sampling.probability=1.0 19 | 20 | ######## Database Configuration ######### 21 | spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:35432/postgres} 22 | spring.datasource.username=${DB_USERNAME:postgres} 23 | spring.datasource.password=${DB_PASSWORD:postgres} 24 | spring.jpa.open-in-view=false 25 | spring.jpa.show-sql=true 26 | 27 | ######## RabbitMQ Configuration ######### 28 | spring.rabbitmq.host=${RABBITMQ_HOST:localhost} 29 | spring.rabbitmq.port=${RABBITMQ_PORT:5672} 30 | spring.rabbitmq.username=${RABBITMQ_USERNAME:guest} 31 | spring.rabbitmq.password=${RABBITMQ_PASSWORD:guest} 32 | 33 | ############# Mail Properties ########### 34 | spring.mail.host=${MAIL_HOST:127.0.0.1} 35 | spring.mail.port=${MAIL_PORT:1025} 36 | spring.mail.username=${MAIL_USERNAME:PLACEHOLDER} 37 | spring.mail.password=${MAIL_PASSWORD:PLACEHOLDER} 38 | spring.mail.properties.mail.smtp.auth=true 39 | spring.mail.properties.mail.smtp.starttls.enable=true 40 | -------------------------------------------------------------------------------- /notification-service/src/main/resources/db/migration/V1__create_order_events_table.sql: -------------------------------------------------------------------------------- 1 | create sequence order_event_id_seq start with 1 increment by 50; 2 | 3 | create table order_events 4 | ( 5 | id bigint default nextval('order_event_id_seq') not null, 6 | event_id text not null unique, 7 | created_at timestamp not null, 8 | updated_at timestamp, 9 | primary key (id) 10 | ); 11 | -------------------------------------------------------------------------------- /notification-service/src/test/java/com/sivalabs/bookstore/notifications/AbstractIT.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications; 2 | 3 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 4 | 5 | import com.sivalabs.bookstore.notifications.domain.NotificationService; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.context.annotation.Import; 8 | import org.springframework.test.context.bean.override.mockito.MockitoBean; 9 | 10 | @SpringBootTest(webEnvironment = RANDOM_PORT) 11 | @Import(ContainersConfig.class) 12 | public abstract class AbstractIT { 13 | @MockitoBean 14 | protected NotificationService notificationService; 15 | } 16 | -------------------------------------------------------------------------------- /notification-service/src/test/java/com/sivalabs/bookstore/notifications/ContainersConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications; 2 | 3 | import org.springframework.boot.test.context.TestConfiguration; 4 | import org.springframework.boot.testcontainers.service.connection.ServiceConnection; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.test.context.DynamicPropertyRegistrar; 7 | import org.testcontainers.containers.GenericContainer; 8 | import org.testcontainers.containers.PostgreSQLContainer; 9 | import org.testcontainers.containers.RabbitMQContainer; 10 | import org.testcontainers.utility.DockerImageName; 11 | 12 | @TestConfiguration(proxyBeanMethods = false) 13 | public class ContainersConfig { 14 | @Bean 15 | @ServiceConnection 16 | PostgreSQLContainer postgresContainer() { 17 | return new PostgreSQLContainer<>(DockerImageName.parse("postgres:17-alpine")); 18 | } 19 | 20 | @Bean 21 | @ServiceConnection 22 | RabbitMQContainer rabbitContainer() { 23 | return new RabbitMQContainer(DockerImageName.parse("rabbitmq:4.0.4-alpine")); 24 | } 25 | 26 | @Bean 27 | GenericContainer mailhog() { 28 | return new GenericContainer<>(DockerImageName.parse("mailhog/mailhog:v1.0.1")).withExposedPorts(1025); 29 | } 30 | 31 | @Bean 32 | DynamicPropertyRegistrar dynamicPropertyRegistrar(GenericContainer mailhog) { 33 | return (registry) -> { 34 | registry.add("spring.mail.host", mailhog::getHost); 35 | registry.add("spring.mail.port", mailhog::getFirstMappedPort); 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /notification-service/src/test/java/com/sivalabs/bookstore/notifications/NotificationServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | class NotificationServiceApplicationTests extends AbstractIT { 6 | 7 | @Test 8 | void contextLoads() {} 9 | } 10 | -------------------------------------------------------------------------------- /notification-service/src/test/java/com/sivalabs/bookstore/notifications/TestNotificationServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | 5 | public class TestNotificationServiceApplication { 6 | 7 | public static void main(String[] args) { 8 | SpringApplication.from(NotificationServiceApplication::main) 9 | .with(ContainersConfig.class) 10 | .run(args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /notification-service/src/test/java/com/sivalabs/bookstore/notifications/events/OrderEventHandlerTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications.events; 2 | 3 | import static java.util.concurrent.TimeUnit.SECONDS; 4 | import static org.awaitility.Awaitility.await; 5 | import static org.mockito.ArgumentMatchers.any; 6 | import static org.mockito.Mockito.verify; 7 | 8 | import com.fasterxml.jackson.databind.ObjectMapper; 9 | import com.sivalabs.bookstore.notifications.AbstractIT; 10 | import com.sivalabs.bookstore.notifications.ApplicationProperties; 11 | import com.sivalabs.bookstore.notifications.domain.models.Address; 12 | import com.sivalabs.bookstore.notifications.domain.models.Customer; 13 | import com.sivalabs.bookstore.notifications.domain.models.OrderCancelledEvent; 14 | import com.sivalabs.bookstore.notifications.domain.models.OrderCreatedEvent; 15 | import com.sivalabs.bookstore.notifications.domain.models.OrderDeliveredEvent; 16 | import com.sivalabs.bookstore.notifications.domain.models.OrderErrorEvent; 17 | import java.time.LocalDateTime; 18 | import java.util.Set; 19 | import java.util.UUID; 20 | import org.junit.jupiter.api.Test; 21 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 22 | import org.springframework.beans.factory.annotation.Autowired; 23 | 24 | class OrderEventHandlerTests extends AbstractIT { 25 | @Autowired 26 | RabbitTemplate rabbitTemplate; 27 | 28 | @Autowired 29 | ObjectMapper objectMapper; 30 | 31 | @Autowired 32 | ApplicationProperties properties; 33 | 34 | Customer customer = new Customer("Siva", "siva@gmail.com", "999999999"); 35 | Address address = new Address("addr line 1", null, "Hyderabad", "TS", "500072", "India"); 36 | 37 | @Test 38 | void shouldHandleOrderCreatedEvent() { 39 | String orderNumber = UUID.randomUUID().toString(); 40 | 41 | var event = new OrderCreatedEvent( 42 | UUID.randomUUID().toString(), orderNumber, Set.of(), customer, address, LocalDateTime.now()); 43 | rabbitTemplate.convertAndSend(properties.orderEventsExchange(), properties.newOrdersQueue(), event); 44 | 45 | await().atMost(30, SECONDS).untilAsserted(() -> { 46 | verify(notificationService).sendOrderCreatedNotification(any(OrderCreatedEvent.class)); 47 | }); 48 | } 49 | 50 | @Test 51 | void shouldHandleOrderDeliveredEvent() { 52 | String orderNumber = UUID.randomUUID().toString(); 53 | 54 | var event = new OrderDeliveredEvent( 55 | UUID.randomUUID().toString(), orderNumber, Set.of(), customer, address, LocalDateTime.now()); 56 | rabbitTemplate.convertAndSend(properties.orderEventsExchange(), properties.deliveredOrdersQueue(), event); 57 | 58 | await().atMost(30, SECONDS).untilAsserted(() -> { 59 | verify(notificationService).sendOrderDeliveredNotification(any(OrderDeliveredEvent.class)); 60 | }); 61 | } 62 | 63 | @Test 64 | void shouldHandleOrderCancelledEvent() { 65 | String orderNumber = UUID.randomUUID().toString(); 66 | 67 | var event = new OrderCancelledEvent( 68 | UUID.randomUUID().toString(), 69 | orderNumber, 70 | Set.of(), 71 | customer, 72 | address, 73 | "test cancel reason", 74 | LocalDateTime.now()); 75 | rabbitTemplate.convertAndSend(properties.orderEventsExchange(), properties.cancelledOrdersQueue(), event); 76 | 77 | await().atMost(30, SECONDS).untilAsserted(() -> { 78 | verify(notificationService).sendOrderCancelledNotification(any(OrderCancelledEvent.class)); 79 | }); 80 | } 81 | 82 | @Test 83 | void shouldHandleOrderErrorEvent() { 84 | String orderNumber = UUID.randomUUID().toString(); 85 | 86 | var event = new OrderErrorEvent( 87 | UUID.randomUUID().toString(), 88 | orderNumber, 89 | Set.of(), 90 | customer, 91 | address, 92 | "test error reason", 93 | LocalDateTime.now()); 94 | rabbitTemplate.convertAndSend(properties.orderEventsExchange(), properties.errorOrdersQueue(), event); 95 | 96 | await().atMost(30, SECONDS).untilAsserted(() -> { 97 | verify(notificationService).sendOrderErrorEventNotification(any(OrderErrorEvent.class)); 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /order-service/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /order-service/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-boot-microservices-course/5caa9109d6c21020d0724745b1577c2816b64099/order-service/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /order-service/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/ApplicationProperties.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | 5 | @ConfigurationProperties(prefix = "orders") 6 | public record ApplicationProperties( 7 | String catalogServiceUrl, 8 | String orderEventsExchange, 9 | String newOrdersQueue, 10 | String deliveredOrdersQueue, 11 | String cancelledOrdersQueue, 12 | String errorOrdersQueue) {} 13 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/OrderServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; 4 | import org.springframework.boot.SpringApplication; 5 | import org.springframework.boot.autoconfigure.SpringBootApplication; 6 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 7 | import org.springframework.scheduling.annotation.EnableScheduling; 8 | 9 | @SpringBootApplication 10 | @ConfigurationPropertiesScan 11 | @EnableScheduling 12 | @EnableSchedulerLock(defaultLockAtMostFor = "10m") 13 | public class OrderServiceApplication { 14 | 15 | public static void main(String[] args) { 16 | SpringApplication.run(OrderServiceApplication.class, args); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/clients/catalog/CatalogServiceClientConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.clients.catalog; 2 | 3 | import com.sivalabs.bookstore.orders.ApplicationProperties; 4 | import java.time.Duration; 5 | import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.http.client.ClientHttpRequestFactory; 9 | import org.springframework.web.client.RestClient; 10 | 11 | @Configuration 12 | class CatalogServiceClientConfig { 13 | @Bean 14 | RestClient restClient(RestClient.Builder builder, ApplicationProperties properties) { 15 | ClientHttpRequestFactory requestFactory = ClientHttpRequestFactoryBuilder.simple() 16 | .withCustomizer(customizer -> { 17 | customizer.setConnectTimeout(Duration.ofSeconds(5)); 18 | customizer.setReadTimeout(Duration.ofSeconds(5)); 19 | }) 20 | .build(); 21 | return builder.baseUrl(properties.catalogServiceUrl()) 22 | .requestFactory(requestFactory) 23 | .build(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/clients/catalog/Product.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.clients.catalog; 2 | 3 | import java.math.BigDecimal; 4 | 5 | public record Product(String code, String name, String description, String imageUrl, BigDecimal price) {} 6 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/clients/catalog/ProductServiceClient.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.clients.catalog; 2 | 3 | import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; 4 | import io.github.resilience4j.retry.annotation.Retry; 5 | import java.util.Optional; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.stereotype.Component; 9 | import org.springframework.web.client.RestClient; 10 | 11 | @Component 12 | public class ProductServiceClient { 13 | private static final Logger log = LoggerFactory.getLogger(ProductServiceClient.class); 14 | 15 | private final RestClient restClient; 16 | 17 | ProductServiceClient(RestClient restClient) { 18 | this.restClient = restClient; 19 | } 20 | 21 | @CircuitBreaker(name = "catalog-service") 22 | @Retry(name = "catalog-service", fallbackMethod = "getProductByCodeFallback") 23 | public Optional getProductByCode(String code) { 24 | log.info("Fetching product for code: {}", code); 25 | var product = 26 | restClient.get().uri("/api/products/{code}", code).retrieve().body(Product.class); 27 | return Optional.ofNullable(product); 28 | } 29 | 30 | Optional getProductByCodeFallback(String code, Throwable t) { 31 | log.info("catalog-service get product by code fallback: code:{}, Error: {} ", code, t.getMessage()); 32 | return Optional.empty(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/config/OpenAPI3Configuration.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.config; 2 | 3 | import io.swagger.v3.oas.models.Components; 4 | import io.swagger.v3.oas.models.OpenAPI; 5 | import io.swagger.v3.oas.models.info.Contact; 6 | import io.swagger.v3.oas.models.info.Info; 7 | import io.swagger.v3.oas.models.security.OAuthFlow; 8 | import io.swagger.v3.oas.models.security.OAuthFlows; 9 | import io.swagger.v3.oas.models.security.Scopes; 10 | import io.swagger.v3.oas.models.security.SecurityRequirement; 11 | import io.swagger.v3.oas.models.security.SecurityScheme; 12 | import io.swagger.v3.oas.models.servers.Server; 13 | import java.util.List; 14 | import org.springframework.beans.factory.annotation.Value; 15 | import org.springframework.context.annotation.Bean; 16 | import org.springframework.context.annotation.Configuration; 17 | 18 | @Configuration 19 | class OpenAPI3Configuration { 20 | 21 | @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") 22 | String issuerUri; 23 | 24 | @Value("${swagger.api-gateway-url}") 25 | String apiGatewayUrl; 26 | 27 | @Bean 28 | OpenAPI openApi() { 29 | return new OpenAPI() 30 | .info(new Info() 31 | .title("Order Service APIs") 32 | .description("BookStore Order Service APIs") 33 | .version("v1.0.0") 34 | .contact(new Contact().name("SivaLabs").email("sivalabs@sivalabs.in"))) 35 | .servers(List.of(new Server().url(apiGatewayUrl))) 36 | .addSecurityItem(new SecurityRequirement().addList("Authorization")) 37 | .components(new Components() 38 | .addSecuritySchemes( 39 | "security_auth", 40 | new SecurityScheme() 41 | .in(SecurityScheme.In.HEADER) 42 | .type(SecurityScheme.Type.OAUTH2) 43 | .flows(new OAuthFlows() 44 | .authorizationCode(new OAuthFlow() 45 | .authorizationUrl(issuerUri + "/protocol/openid-connect/auth") 46 | .tokenUrl(issuerUri + "/protocol/openid-connect/token") 47 | .scopes(new Scopes().addString("openid", "openid scope")))))); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/config/RabbitMQConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.config; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.sivalabs.bookstore.orders.ApplicationProperties; 5 | import org.springframework.amqp.core.Binding; 6 | import org.springframework.amqp.core.BindingBuilder; 7 | import org.springframework.amqp.core.DirectExchange; 8 | import org.springframework.amqp.core.Queue; 9 | import org.springframework.amqp.core.QueueBuilder; 10 | import org.springframework.amqp.rabbit.connection.ConnectionFactory; 11 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 12 | import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; 13 | import org.springframework.context.annotation.Bean; 14 | import org.springframework.context.annotation.Configuration; 15 | 16 | @Configuration 17 | class RabbitMQConfig { 18 | private final ApplicationProperties properties; 19 | 20 | RabbitMQConfig(ApplicationProperties properties) { 21 | this.properties = properties; 22 | } 23 | 24 | @Bean 25 | DirectExchange exchange() { 26 | return new DirectExchange(properties.orderEventsExchange()); 27 | } 28 | 29 | @Bean 30 | Queue newOrdersQueue() { 31 | return QueueBuilder.durable(properties.newOrdersQueue()).build(); 32 | } 33 | 34 | @Bean 35 | Binding newOrdersQueueBinding() { 36 | return BindingBuilder.bind(newOrdersQueue()).to(exchange()).with(properties.newOrdersQueue()); 37 | } 38 | 39 | @Bean 40 | Queue deliveredOrdersQueue() { 41 | return QueueBuilder.durable(properties.deliveredOrdersQueue()).build(); 42 | } 43 | 44 | @Bean 45 | Binding deliveredOrdersQueueBinding() { 46 | return BindingBuilder.bind(deliveredOrdersQueue()).to(exchange()).with(properties.deliveredOrdersQueue()); 47 | } 48 | 49 | @Bean 50 | Queue cancelledOrdersQueue() { 51 | return QueueBuilder.durable(properties.cancelledOrdersQueue()).build(); 52 | } 53 | 54 | @Bean 55 | Binding cancelledOrdersQueueBinding() { 56 | return BindingBuilder.bind(cancelledOrdersQueue()).to(exchange()).with(properties.cancelledOrdersQueue()); 57 | } 58 | 59 | @Bean 60 | Queue errorOrdersQueue() { 61 | return QueueBuilder.durable(properties.errorOrdersQueue()).build(); 62 | } 63 | 64 | @Bean 65 | Binding errorOrdersQueueBinding() { 66 | return BindingBuilder.bind(errorOrdersQueue()).to(exchange()).with(properties.errorOrdersQueue()); 67 | } 68 | 69 | @Bean 70 | public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, ObjectMapper objectMapper) { 71 | final var rabbitTemplate = new RabbitTemplate(connectionFactory); 72 | rabbitTemplate.setMessageConverter(jacksonConverter(objectMapper)); 73 | return rabbitTemplate; 74 | } 75 | 76 | @Bean 77 | public Jackson2JsonMessageConverter jacksonConverter(ObjectMapper mapper) { 78 | return new Jackson2JsonMessageConverter(mapper); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/config/SchedulerConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.config; 2 | 3 | import javax.sql.DataSource; 4 | import net.javacrumbs.shedlock.core.LockProvider; 5 | import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.jdbc.core.JdbcTemplate; 9 | 10 | @Configuration 11 | class SchedulerConfig { 12 | 13 | @Bean 14 | public LockProvider lockProvider(DataSource dataSource) { 15 | return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder() 16 | .withJdbcTemplate(new JdbcTemplate(dataSource)) 17 | .usingDbTime() 18 | .build()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.security.config.Customizer; 6 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 7 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 8 | import org.springframework.security.config.annotation.web.configurers.CorsConfigurer; 9 | import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; 10 | import org.springframework.security.config.http.SessionCreationPolicy; 11 | import org.springframework.security.web.SecurityFilterChain; 12 | 13 | @Configuration 14 | @EnableWebSecurity 15 | class SecurityConfig { 16 | 17 | @Bean 18 | SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 19 | http.authorizeHttpRequests(c -> c.requestMatchers("/actuator/**", "/v3/api-docs/**") 20 | .permitAll() 21 | .anyRequest() 22 | .authenticated()) 23 | .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 24 | .cors(CorsConfigurer::disable) 25 | .csrf(CsrfConfigurer::disable) 26 | .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); 27 | return http.build(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/config/WebMvcConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.config; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.web.servlet.config.annotation.CorsRegistry; 5 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 6 | 7 | @Configuration 8 | class WebMvcConfig implements WebMvcConfigurer { 9 | 10 | @Override 11 | public void addCorsMappings(CorsRegistry registry) { 12 | registry.addMapping("/api/**") 13 | .allowedMethods("*") 14 | .allowedHeaders("*") 15 | .allowedOriginPatterns("*") 16 | .allowCredentials(false); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/InvalidOrderException.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | public class InvalidOrderException extends RuntimeException { 4 | 5 | public InvalidOrderException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderEventEntity.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.OrderEventType; 4 | import jakarta.persistence.Column; 5 | import jakarta.persistence.Entity; 6 | import jakarta.persistence.EnumType; 7 | import jakarta.persistence.Enumerated; 8 | import jakarta.persistence.GeneratedValue; 9 | import jakarta.persistence.GenerationType; 10 | import jakarta.persistence.Id; 11 | import jakarta.persistence.SequenceGenerator; 12 | import jakarta.persistence.Table; 13 | import java.time.LocalDateTime; 14 | 15 | @Entity 16 | @Table(name = "order_events") 17 | class OrderEventEntity { 18 | @Id 19 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_event_id_generator") 20 | @SequenceGenerator(name = "order_event_id_generator", sequenceName = "order_event_id_seq") 21 | private Long id; 22 | 23 | @Column(nullable = false) 24 | private String orderNumber; 25 | 26 | @Column(nullable = false, unique = true) 27 | private String eventId; 28 | 29 | @Enumerated(EnumType.STRING) 30 | private OrderEventType eventType; 31 | 32 | @Column(nullable = false) 33 | private String payload; 34 | 35 | @Column(name = "created_at", nullable = false, updatable = false) 36 | private LocalDateTime createdAt = LocalDateTime.now(); 37 | 38 | @Column(name = "updated_at") 39 | private LocalDateTime updatedAt; 40 | 41 | public Long getId() { 42 | return id; 43 | } 44 | 45 | public void setId(Long id) { 46 | this.id = id; 47 | } 48 | 49 | public String getOrderNumber() { 50 | return orderNumber; 51 | } 52 | 53 | public void setOrderNumber(String orderNumber) { 54 | this.orderNumber = orderNumber; 55 | } 56 | 57 | public String getEventId() { 58 | return eventId; 59 | } 60 | 61 | public void setEventId(String eventId) { 62 | this.eventId = eventId; 63 | } 64 | 65 | public OrderEventType getEventType() { 66 | return eventType; 67 | } 68 | 69 | public void setEventType(OrderEventType eventType) { 70 | this.eventType = eventType; 71 | } 72 | 73 | public String getPayload() { 74 | return payload; 75 | } 76 | 77 | public void setPayload(String payload) { 78 | this.payload = payload; 79 | } 80 | 81 | public LocalDateTime getCreatedAt() { 82 | return createdAt; 83 | } 84 | 85 | public void setCreatedAt(LocalDateTime createdAt) { 86 | this.createdAt = createdAt; 87 | } 88 | 89 | public LocalDateTime getUpdatedAt() { 90 | return updatedAt; 91 | } 92 | 93 | public void setUpdatedAt(LocalDateTime updatedAt) { 94 | this.updatedAt = updatedAt; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderEventMapper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.OrderCancelledEvent; 4 | import com.sivalabs.bookstore.orders.domain.models.OrderCreatedEvent; 5 | import com.sivalabs.bookstore.orders.domain.models.OrderDeliveredEvent; 6 | import com.sivalabs.bookstore.orders.domain.models.OrderErrorEvent; 7 | import com.sivalabs.bookstore.orders.domain.models.OrderItem; 8 | import java.time.LocalDateTime; 9 | import java.util.Set; 10 | import java.util.UUID; 11 | import java.util.stream.Collectors; 12 | 13 | class OrderEventMapper { 14 | 15 | static OrderCreatedEvent buildOrderCreatedEvent(OrderEntity order) { 16 | return new OrderCreatedEvent( 17 | UUID.randomUUID().toString(), 18 | order.getOrderNumber(), 19 | getOrderItems(order), 20 | order.getCustomer(), 21 | order.getDeliveryAddress(), 22 | LocalDateTime.now()); 23 | } 24 | 25 | static OrderDeliveredEvent buildOrderDeliveredEvent(OrderEntity order) { 26 | return new OrderDeliveredEvent( 27 | UUID.randomUUID().toString(), 28 | order.getOrderNumber(), 29 | getOrderItems(order), 30 | order.getCustomer(), 31 | order.getDeliveryAddress(), 32 | LocalDateTime.now()); 33 | } 34 | 35 | static OrderCancelledEvent buildOrderCancelledEvent(OrderEntity order, String reason) { 36 | return new OrderCancelledEvent( 37 | UUID.randomUUID().toString(), 38 | order.getOrderNumber(), 39 | getOrderItems(order), 40 | order.getCustomer(), 41 | order.getDeliveryAddress(), 42 | reason, 43 | LocalDateTime.now()); 44 | } 45 | 46 | static OrderErrorEvent buildOrderErrorEvent(OrderEntity order, String reason) { 47 | return new OrderErrorEvent( 48 | UUID.randomUUID().toString(), 49 | order.getOrderNumber(), 50 | getOrderItems(order), 51 | order.getCustomer(), 52 | order.getDeliveryAddress(), 53 | reason, 54 | LocalDateTime.now()); 55 | } 56 | 57 | private static Set getOrderItems(OrderEntity order) { 58 | return order.getItems().stream() 59 | .map(item -> new OrderItem(item.getCode(), item.getName(), item.getPrice(), item.getQuantity())) 60 | .collect(Collectors.toSet()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderEventPublisher.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import com.sivalabs.bookstore.orders.ApplicationProperties; 4 | import com.sivalabs.bookstore.orders.domain.models.OrderCancelledEvent; 5 | import com.sivalabs.bookstore.orders.domain.models.OrderCreatedEvent; 6 | import com.sivalabs.bookstore.orders.domain.models.OrderDeliveredEvent; 7 | import com.sivalabs.bookstore.orders.domain.models.OrderErrorEvent; 8 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 9 | import org.springframework.stereotype.Component; 10 | 11 | @Component 12 | class OrderEventPublisher { 13 | private final RabbitTemplate rabbitTemplate; 14 | private final ApplicationProperties properties; 15 | 16 | OrderEventPublisher(RabbitTemplate rabbitTemplate, ApplicationProperties properties) { 17 | this.rabbitTemplate = rabbitTemplate; 18 | this.properties = properties; 19 | } 20 | 21 | public void publish(OrderCreatedEvent event) { 22 | this.send(properties.newOrdersQueue(), event); 23 | } 24 | 25 | public void publish(OrderDeliveredEvent event) { 26 | this.send(properties.deliveredOrdersQueue(), event); 27 | } 28 | 29 | public void publish(OrderCancelledEvent event) { 30 | this.send(properties.cancelledOrdersQueue(), event); 31 | } 32 | 33 | public void publish(OrderErrorEvent event) { 34 | this.send(properties.errorOrdersQueue(), event); 35 | } 36 | 37 | private void send(String routingKey, Object payload) { 38 | rabbitTemplate.convertAndSend(properties.orderEventsExchange(), routingKey, payload); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderEventRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | interface OrderEventRepository extends JpaRepository {} 6 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderItemEntity.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.GeneratedValue; 6 | import jakarta.persistence.GenerationType; 7 | import jakarta.persistence.Id; 8 | import jakarta.persistence.JoinColumn; 9 | import jakarta.persistence.ManyToOne; 10 | import jakarta.persistence.SequenceGenerator; 11 | import jakarta.persistence.Table; 12 | import java.math.BigDecimal; 13 | 14 | @Entity 15 | @Table(name = "order_items") 16 | class OrderItemEntity { 17 | 18 | @Id 19 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_item_id_generator") 20 | @SequenceGenerator(name = "order_item_id_generator", sequenceName = "order_item_id_seq") 21 | private Long id; 22 | 23 | @Column(nullable = false) 24 | private String code; 25 | 26 | private String name; 27 | 28 | @Column(nullable = false) 29 | private BigDecimal price; 30 | 31 | @Column(nullable = false) 32 | private Integer quantity; 33 | 34 | @ManyToOne(optional = false) 35 | @JoinColumn(name = "order_id") 36 | private OrderEntity order; 37 | 38 | public Long getId() { 39 | return id; 40 | } 41 | 42 | public void setId(Long id) { 43 | this.id = id; 44 | } 45 | 46 | public String getCode() { 47 | return code; 48 | } 49 | 50 | public void setCode(String code) { 51 | this.code = code; 52 | } 53 | 54 | public String getName() { 55 | return name; 56 | } 57 | 58 | public void setName(String name) { 59 | this.name = name; 60 | } 61 | 62 | public BigDecimal getPrice() { 63 | return price; 64 | } 65 | 66 | public void setPrice(BigDecimal price) { 67 | this.price = price; 68 | } 69 | 70 | public Integer getQuantity() { 71 | return quantity; 72 | } 73 | 74 | public void setQuantity(Integer quantity) { 75 | this.quantity = quantity; 76 | } 77 | 78 | public OrderEntity getOrder() { 79 | return order; 80 | } 81 | 82 | public void setOrder(OrderEntity order) { 83 | this.order = order; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderMapper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.CreateOrderRequest; 4 | import com.sivalabs.bookstore.orders.domain.models.OrderDTO; 5 | import com.sivalabs.bookstore.orders.domain.models.OrderItem; 6 | import com.sivalabs.bookstore.orders.domain.models.OrderStatus; 7 | import java.util.HashSet; 8 | import java.util.Set; 9 | import java.util.UUID; 10 | import java.util.stream.Collectors; 11 | 12 | class OrderMapper { 13 | 14 | static OrderEntity convertToEntity(CreateOrderRequest request) { 15 | OrderEntity newOrder = new OrderEntity(); 16 | newOrder.setOrderNumber(UUID.randomUUID().toString()); 17 | newOrder.setStatus(OrderStatus.NEW); 18 | newOrder.setCustomer(request.customer()); 19 | newOrder.setDeliveryAddress(request.deliveryAddress()); 20 | Set orderItems = new HashSet<>(); 21 | for (OrderItem item : request.items()) { 22 | OrderItemEntity orderItem = new OrderItemEntity(); 23 | orderItem.setCode(item.code()); 24 | orderItem.setName(item.name()); 25 | orderItem.setPrice(item.price()); 26 | orderItem.setQuantity(item.quantity()); 27 | orderItem.setOrder(newOrder); 28 | orderItems.add(orderItem); 29 | } 30 | newOrder.setItems(orderItems); 31 | return newOrder; 32 | } 33 | 34 | static OrderDTO convertToDTO(OrderEntity order) { 35 | Set orderItems = order.getItems().stream() 36 | .map(item -> new OrderItem(item.getCode(), item.getName(), item.getPrice(), item.getQuantity())) 37 | .collect(Collectors.toSet()); 38 | 39 | return new OrderDTO( 40 | order.getOrderNumber(), 41 | order.getUserName(), 42 | orderItems, 43 | order.getCustomer(), 44 | order.getDeliveryAddress(), 45 | order.getStatus(), 46 | order.getComments(), 47 | order.getCreatedAt()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | public class OrderNotFoundException extends RuntimeException { 4 | public OrderNotFoundException(String message) { 5 | super(message); 6 | } 7 | 8 | public static OrderNotFoundException forOrderNumber(String orderNumber) { 9 | return new OrderNotFoundException("Order with Number " + orderNumber + " not found"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.OrderStatus; 4 | import com.sivalabs.bookstore.orders.domain.models.OrderSummary; 5 | import java.util.List; 6 | import java.util.Optional; 7 | import org.springframework.data.jpa.repository.JpaRepository; 8 | import org.springframework.data.jpa.repository.Query; 9 | 10 | interface OrderRepository extends JpaRepository { 11 | List findByStatus(OrderStatus status); 12 | 13 | Optional findByOrderNumber(String orderNumber); 14 | 15 | default void updateOrderStatus(String orderNumber, OrderStatus status) { 16 | OrderEntity order = this.findByOrderNumber(orderNumber).orElseThrow(); 17 | order.setStatus(status); 18 | this.save(order); 19 | } 20 | 21 | @Query( 22 | """ 23 | select new com.sivalabs.bookstore.orders.domain.models.OrderSummary(o.orderNumber, o.status) 24 | from OrderEntity o 25 | where o.userName = :userName 26 | """) 27 | List findByUserName(String userName); 28 | 29 | @Query( 30 | """ 31 | select distinct o 32 | from OrderEntity o left join fetch o.items 33 | where o.userName = :userName and o.orderNumber = :orderNumber 34 | """) 35 | Optional findByUserNameAndOrderNumber(String userName, String orderNumber); 36 | } 37 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.CreateOrderRequest; 4 | import com.sivalabs.bookstore.orders.domain.models.CreateOrderResponse; 5 | import com.sivalabs.bookstore.orders.domain.models.OrderCreatedEvent; 6 | import com.sivalabs.bookstore.orders.domain.models.OrderDTO; 7 | import com.sivalabs.bookstore.orders.domain.models.OrderStatus; 8 | import com.sivalabs.bookstore.orders.domain.models.OrderSummary; 9 | import java.util.List; 10 | import java.util.Optional; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.transaction.annotation.Transactional; 15 | 16 | @Service 17 | @Transactional 18 | public class OrderService { 19 | private static final Logger log = LoggerFactory.getLogger(OrderService.class); 20 | private static final List DELIVERY_ALLOWED_COUNTRIES = List.of("INDIA", "USA", "GERMANY", "UK"); 21 | 22 | private final OrderRepository orderRepository; 23 | private final OrderValidator orderValidator; 24 | private final OrderEventService orderEventService; 25 | 26 | OrderService(OrderRepository orderRepository, OrderValidator orderValidator, OrderEventService orderEventService) { 27 | this.orderRepository = orderRepository; 28 | this.orderValidator = orderValidator; 29 | this.orderEventService = orderEventService; 30 | } 31 | 32 | public CreateOrderResponse createOrder(String userName, CreateOrderRequest request) { 33 | orderValidator.validate(request); 34 | OrderEntity newOrder = OrderMapper.convertToEntity(request); 35 | newOrder.setUserName(userName); 36 | OrderEntity savedOrder = this.orderRepository.save(newOrder); 37 | log.info("Created Order with orderNumber={}", savedOrder.getOrderNumber()); 38 | OrderCreatedEvent orderCreatedEvent = OrderEventMapper.buildOrderCreatedEvent(savedOrder); 39 | orderEventService.save(orderCreatedEvent); 40 | return new CreateOrderResponse(savedOrder.getOrderNumber()); 41 | } 42 | 43 | public List findOrders(String userName) { 44 | return orderRepository.findByUserName(userName); 45 | } 46 | 47 | public Optional findUserOrder(String userName, String orderNumber) { 48 | return orderRepository 49 | .findByUserNameAndOrderNumber(userName, orderNumber) 50 | .map(OrderMapper::convertToDTO); 51 | } 52 | 53 | public void processNewOrders() { 54 | List orders = orderRepository.findByStatus(OrderStatus.NEW); 55 | log.info("Found {} new orders to process", orders.size()); 56 | for (OrderEntity order : orders) { 57 | this.process(order); 58 | } 59 | } 60 | 61 | private void process(OrderEntity order) { 62 | try { 63 | if (canBeDelivered(order)) { 64 | log.info("OrderNumber: {} can be delivered", order.getOrderNumber()); 65 | orderRepository.updateOrderStatus(order.getOrderNumber(), OrderStatus.DELIVERED); 66 | orderEventService.save(OrderEventMapper.buildOrderDeliveredEvent(order)); 67 | 68 | } else { 69 | log.info("OrderNumber: {} can not be delivered", order.getOrderNumber()); 70 | orderRepository.updateOrderStatus(order.getOrderNumber(), OrderStatus.CANCELLED); 71 | orderEventService.save( 72 | OrderEventMapper.buildOrderCancelledEvent(order, "Can't deliver to the location")); 73 | } 74 | } catch (RuntimeException e) { 75 | log.error("Failed to process Order with orderNumber: {}", order.getOrderNumber(), e); 76 | orderRepository.updateOrderStatus(order.getOrderNumber(), OrderStatus.ERROR); 77 | orderEventService.save(OrderEventMapper.buildOrderErrorEvent(order, e.getMessage())); 78 | } 79 | } 80 | 81 | private boolean canBeDelivered(OrderEntity order) { 82 | return DELIVERY_ALLOWED_COUNTRIES.contains( 83 | order.getDeliveryAddress().country().toUpperCase()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/OrderValidator.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import com.sivalabs.bookstore.orders.clients.catalog.Product; 4 | import com.sivalabs.bookstore.orders.clients.catalog.ProductServiceClient; 5 | import com.sivalabs.bookstore.orders.domain.models.CreateOrderRequest; 6 | import com.sivalabs.bookstore.orders.domain.models.OrderItem; 7 | import java.util.Set; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | class OrderValidator { 14 | private static final Logger log = LoggerFactory.getLogger(OrderValidator.class); 15 | 16 | private final ProductServiceClient client; 17 | 18 | OrderValidator(ProductServiceClient client) { 19 | this.client = client; 20 | } 21 | 22 | void validate(CreateOrderRequest request) { 23 | Set items = request.items(); 24 | for (OrderItem item : items) { 25 | Product product = client.getProductByCode(item.code()) 26 | .orElseThrow(() -> new InvalidOrderException("Invalid Product code:" + item.code())); 27 | if (item.price().compareTo(product.price()) != 0) { 28 | log.error( 29 | "Product price not matching. Actual price:{}, received price:{}", 30 | product.price(), 31 | item.price()); 32 | throw new InvalidOrderException("Product price not matching"); 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/SecurityService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import org.springframework.security.core.context.SecurityContextHolder; 4 | import org.springframework.security.oauth2.jwt.Jwt; 5 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; 6 | import org.springframework.stereotype.Service; 7 | 8 | @Service 9 | public class SecurityService { 10 | 11 | public String getLoginUserName() { 12 | // return "user"; 13 | JwtAuthenticationToken authentication = 14 | (JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); 15 | Jwt jwt = (Jwt) authentication.getPrincipal(); 16 | /* 17 | var username = jwt.getClaimAsString("preferred_username"); 18 | var email = jwt.getClaimAsString("email"); 19 | var name = jwt.getClaimAsString("name"); 20 | var token = jwt.getTokenValue(); 21 | var authorities = authentication.getAuthorities(); 22 | */ 23 | return jwt.getClaimAsString("preferred_username"); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/Address.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | 5 | public record Address( 6 | @NotBlank(message = "AddressLine1 is required") String addressLine1, 7 | String addressLine2, 8 | @NotBlank(message = "City is required") String city, 9 | @NotBlank(message = "State is required") String state, 10 | @NotBlank(message = "ZipCode is required") String zipCode, 11 | @NotBlank(message = "Country is required") String country) {} 12 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/CreateOrderRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import jakarta.validation.Valid; 4 | import jakarta.validation.constraints.NotEmpty; 5 | import java.util.Set; 6 | 7 | public record CreateOrderRequest( 8 | @Valid @NotEmpty(message = "Items cannot be empty") Set items, 9 | @Valid Customer customer, 10 | @Valid Address deliveryAddress) {} 11 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/CreateOrderResponse.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | public record CreateOrderResponse(String orderNumber) {} 4 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/Customer.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import jakarta.validation.constraints.Email; 4 | import jakarta.validation.constraints.NotBlank; 5 | 6 | public record Customer( 7 | @NotBlank(message = "Customer Name is required") String name, 8 | @NotBlank(message = "Customer email is required") @Email String email, 9 | @NotBlank(message = "Customer Phone number is required") String phone) {} 10 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderCancelledEvent.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.Set; 5 | 6 | public record OrderCancelledEvent( 7 | String eventId, 8 | String orderNumber, 9 | Set items, 10 | Customer customer, 11 | Address deliveryAddress, 12 | String reason, 13 | LocalDateTime createdAt) {} 14 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderCreatedEvent.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.Set; 5 | 6 | public record OrderCreatedEvent( 7 | String eventId, 8 | String orderNumber, 9 | Set items, 10 | Customer customer, 11 | Address deliveryAddress, 12 | LocalDateTime createdAt) {} 13 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderDTO.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import java.math.BigDecimal; 5 | import java.time.LocalDateTime; 6 | import java.util.Set; 7 | 8 | public record OrderDTO( 9 | String orderNumber, 10 | String user, 11 | Set items, 12 | Customer customer, 13 | Address deliveryAddress, 14 | OrderStatus status, 15 | String comments, 16 | LocalDateTime createdAt) { 17 | 18 | @JsonProperty(access = JsonProperty.Access.READ_ONLY) 19 | public BigDecimal getTotalAmount() { 20 | return items.stream() 21 | .map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity()))) 22 | .reduce(BigDecimal.ZERO, BigDecimal::add); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderDeliveredEvent.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.Set; 5 | 6 | public record OrderDeliveredEvent( 7 | String eventId, 8 | String orderNumber, 9 | Set items, 10 | Customer customer, 11 | Address deliveryAddress, 12 | LocalDateTime createdAt) {} 13 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderErrorEvent.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import java.time.LocalDateTime; 4 | import java.util.Set; 5 | 6 | public record OrderErrorEvent( 7 | String eventId, 8 | String orderNumber, 9 | Set items, 10 | Customer customer, 11 | Address deliveryAddress, 12 | String reason, 13 | LocalDateTime createdAt) {} 14 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderEventType.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | public enum OrderEventType { 4 | ORDER_CREATED, 5 | ORDER_DELIVERED, 6 | ORDER_CANCELLED, 7 | ORDER_PROCESSING_FAILED 8 | } 9 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderItem.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import jakarta.validation.constraints.Min; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.NotNull; 6 | import java.math.BigDecimal; 7 | 8 | public record OrderItem( 9 | @NotBlank(message = "Code is required") String code, 10 | @NotBlank(message = "Name is required") String name, 11 | @NotNull(message = "Price is required") BigDecimal price, 12 | @NotNull(message = "Quantity is required") @Min(value = 1, message = "Min quantity must be 1") Integer quantity) {} 13 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderStatus.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | public enum OrderStatus { 4 | NEW, 5 | IN_PROCESS, 6 | DELIVERED, 7 | CANCELLED, 8 | ERROR 9 | } 10 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderSummary.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | public record OrderSummary(String orderNumber, OrderStatus status) {} 4 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/jobs/OrderEventsPublishingJob.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.jobs; 2 | 3 | import com.sivalabs.bookstore.orders.domain.OrderEventService; 4 | import java.time.Instant; 5 | import net.javacrumbs.shedlock.core.LockAssert; 6 | import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.scheduling.annotation.Scheduled; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | class OrderEventsPublishingJob { 14 | private static final Logger log = LoggerFactory.getLogger(OrderEventsPublishingJob.class); 15 | 16 | private final OrderEventService orderEventService; 17 | 18 | OrderEventsPublishingJob(OrderEventService orderEventService) { 19 | this.orderEventService = orderEventService; 20 | } 21 | 22 | @Scheduled(cron = "${orders.publish-order-events-job-cron}") 23 | @SchedulerLock(name = "publishOrderEvents") 24 | public void publishOrderEvents() { 25 | LockAssert.assertLocked(); 26 | log.info("Publishing Order Events at {}", Instant.now()); 27 | orderEventService.publishOrderEvents(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/jobs/OrderProcessingJob.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.jobs; 2 | 3 | import com.sivalabs.bookstore.orders.domain.OrderService; 4 | import java.time.Instant; 5 | import net.javacrumbs.shedlock.core.LockAssert; 6 | import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.scheduling.annotation.Scheduled; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | class OrderProcessingJob { 14 | private static final Logger log = LoggerFactory.getLogger(OrderProcessingJob.class); 15 | 16 | private final OrderService orderService; 17 | 18 | OrderProcessingJob(OrderService orderService) { 19 | this.orderService = orderService; 20 | } 21 | 22 | @Scheduled(cron = "${orders.new-orders-job-cron}") 23 | @SchedulerLock(name = "processNewOrders") 24 | public void processNewOrders() { 25 | LockAssert.assertLocked(); 26 | log.info("Processing new orders at {}", Instant.now()); 27 | orderService.processNewOrders(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/web/controllers/OrderController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web.controllers; 2 | 3 | import com.sivalabs.bookstore.orders.domain.OrderNotFoundException; 4 | import com.sivalabs.bookstore.orders.domain.OrderService; 5 | import com.sivalabs.bookstore.orders.domain.SecurityService; 6 | import com.sivalabs.bookstore.orders.domain.models.CreateOrderRequest; 7 | import com.sivalabs.bookstore.orders.domain.models.CreateOrderResponse; 8 | import com.sivalabs.bookstore.orders.domain.models.OrderDTO; 9 | import com.sivalabs.bookstore.orders.domain.models.OrderSummary; 10 | import io.swagger.v3.oas.annotations.security.SecurityRequirement; 11 | import jakarta.validation.Valid; 12 | import java.util.List; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | import org.springframework.http.HttpStatus; 16 | import org.springframework.web.bind.annotation.GetMapping; 17 | import org.springframework.web.bind.annotation.PathVariable; 18 | import org.springframework.web.bind.annotation.PostMapping; 19 | import org.springframework.web.bind.annotation.RequestBody; 20 | import org.springframework.web.bind.annotation.RequestMapping; 21 | import org.springframework.web.bind.annotation.ResponseStatus; 22 | import org.springframework.web.bind.annotation.RestController; 23 | 24 | @RestController 25 | @RequestMapping("/api/orders") 26 | @SecurityRequirement(name = "security_auth") 27 | class OrderController { 28 | private static final Logger log = LoggerFactory.getLogger(OrderController.class); 29 | 30 | private final OrderService orderService; 31 | private final SecurityService securityService; 32 | 33 | OrderController(OrderService orderService, SecurityService securityService) { 34 | this.orderService = orderService; 35 | this.securityService = securityService; 36 | } 37 | 38 | @PostMapping 39 | @ResponseStatus(HttpStatus.CREATED) 40 | CreateOrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) { 41 | String userName = securityService.getLoginUserName(); 42 | log.info("Creating order for user: {}", userName); 43 | return orderService.createOrder(userName, request); 44 | } 45 | 46 | @GetMapping 47 | List getOrders() { 48 | String userName = securityService.getLoginUserName(); 49 | log.info("Fetching orders for user: {}", userName); 50 | return orderService.findOrders(userName); 51 | } 52 | 53 | @GetMapping(value = "/{orderNumber}") 54 | OrderDTO getOrder(@PathVariable(value = "orderNumber") String orderNumber) { 55 | log.info("Fetching order by id: {}", orderNumber); 56 | String userName = securityService.getLoginUserName(); 57 | return orderService 58 | .findUserOrder(userName, orderNumber) 59 | .orElseThrow(() -> new OrderNotFoundException(orderNumber)); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /order-service/src/main/java/com/sivalabs/bookstore/orders/web/exception/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web.exception; 2 | 3 | import com.sivalabs.bookstore.orders.domain.InvalidOrderException; 4 | import com.sivalabs.bookstore.orders.domain.OrderNotFoundException; 5 | import java.net.URI; 6 | import java.time.Instant; 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | import org.springframework.http.HttpHeaders; 10 | import org.springframework.http.HttpStatus; 11 | import org.springframework.http.HttpStatusCode; 12 | import org.springframework.http.ProblemDetail; 13 | import org.springframework.http.ResponseEntity; 14 | import org.springframework.lang.Nullable; 15 | import org.springframework.web.bind.MethodArgumentNotValidException; 16 | import org.springframework.web.bind.annotation.ExceptionHandler; 17 | import org.springframework.web.bind.annotation.RestControllerAdvice; 18 | import org.springframework.web.context.request.WebRequest; 19 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 20 | 21 | @RestControllerAdvice 22 | class GlobalExceptionHandler extends ResponseEntityExceptionHandler { 23 | private static final URI NOT_FOUND_TYPE = URI.create("https://api.bookstore.com/errors/not-found"); 24 | private static final URI ISE_FOUND_TYPE = URI.create("https://api.bookstore.com/errors/server-error"); 25 | private static final URI BAD_REQUEST_TYPE = URI.create("https://api.bookstore.com/errors/bad-request"); 26 | private static final String SERVICE_NAME = "order-service"; 27 | 28 | @ExceptionHandler(Exception.class) 29 | ProblemDetail handleUnhandledException(Exception e) { 30 | ProblemDetail problemDetail = 31 | ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); 32 | problemDetail.setTitle("Internal Server Error"); 33 | problemDetail.setType(ISE_FOUND_TYPE); 34 | problemDetail.setProperty("service", SERVICE_NAME); 35 | problemDetail.setProperty("error_category", "Generic"); 36 | problemDetail.setProperty("timestamp", Instant.now()); 37 | return problemDetail; 38 | } 39 | 40 | @ExceptionHandler(OrderNotFoundException.class) 41 | ProblemDetail handleOrderNotFoundException(OrderNotFoundException e) { 42 | ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); 43 | problemDetail.setTitle("Order Not Found"); 44 | problemDetail.setType(NOT_FOUND_TYPE); 45 | problemDetail.setProperty("service", SERVICE_NAME); 46 | problemDetail.setProperty("error_category", "Generic"); 47 | problemDetail.setProperty("timestamp", Instant.now()); 48 | return problemDetail; 49 | } 50 | 51 | @ExceptionHandler(InvalidOrderException.class) 52 | ProblemDetail handleInvalidOrderException(InvalidOrderException e) { 53 | ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage()); 54 | problemDetail.setTitle("Invalid Order Creation Request"); 55 | problemDetail.setType(BAD_REQUEST_TYPE); 56 | problemDetail.setProperty("service", SERVICE_NAME); 57 | problemDetail.setProperty("error_category", "Generic"); 58 | problemDetail.setProperty("timestamp", Instant.now()); 59 | return problemDetail; 60 | } 61 | 62 | @Override 63 | @Nullable protected ResponseEntity handleMethodArgumentNotValid( 64 | MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { 65 | List errors = new ArrayList<>(); 66 | ex.getBindingResult().getAllErrors().forEach((error) -> { 67 | String errorMessage = error.getDefaultMessage(); 68 | errors.add(errorMessage); 69 | }); 70 | ProblemDetail problemDetail = 71 | ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Invalid request payload"); 72 | problemDetail.setTitle("Bad Request"); 73 | problemDetail.setType(BAD_REQUEST_TYPE); 74 | problemDetail.setProperty("errors", errors); 75 | problemDetail.setProperty("service", SERVICE_NAME); 76 | problemDetail.setProperty("error_category", "Generic"); 77 | problemDetail.setProperty("timestamp", Instant.now()); 78 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /order-service/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=order-service 2 | server.port=8082 3 | server.shutdown=graceful 4 | 5 | ######## Order Service Configuration ######### 6 | orders.catalog-service-url=http://localhost:8081 7 | orders.order-events-exchange=orders-exchange 8 | orders.new-orders-queue=new-orders 9 | orders.delivered-orders-queue=delivered-orders 10 | orders.cancelled-orders-queue=cancelled-orders 11 | orders.error-orders-queue=error-orders 12 | 13 | orders.publish-order-events-job-cron=*/5 * * * * * 14 | orders.new-orders-job-cron=*/10 * * * * * 15 | 16 | ######## Actuator Configuration ######### 17 | management.info.git.mode=full 18 | management.endpoints.web.exposure.include=* 19 | management.metrics.tags.application=${spring.application.name} 20 | management.tracing.enabled=false 21 | management.tracing.sampling.probability=1.0 22 | 23 | ######### Swagger Configuration ######### 24 | swagger.api-gateway-url=http://localhost:8989/orders 25 | 26 | ####### OAuth2 Configuration ######### 27 | OAUTH2_SERVER_URL=http://localhost:9191 28 | REALM_URL=${OAUTH2_SERVER_URL}/realms/bookstore 29 | spring.security.oauth2.resourceserver.jwt.issuer-uri=${REALM_URL} 30 | 31 | ######## Database Configuration ######### 32 | spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:25432/postgres} 33 | spring.datasource.username=${DB_USERNAME:postgres} 34 | spring.datasource.password=${DB_PASSWORD:postgres} 35 | spring.jpa.open-in-view=false 36 | spring.jpa.show-sql=true 37 | 38 | ######## RabbitMQ Configuration ######### 39 | spring.rabbitmq.host=${RABBITMQ_HOST:localhost} 40 | spring.rabbitmq.port=${RABBITMQ_PORT:5672} 41 | spring.rabbitmq.username=${RABBITMQ_USERNAME:guest} 42 | spring.rabbitmq.password=${RABBITMQ_PASSWORD:guest} 43 | 44 | ## Resilience4j Configuration 45 | resilience4j.retry.backends.catalog-service.max-attempts=2 46 | resilience4j.retry.backends.catalog-service.wait-duration=1s 47 | 48 | resilience4j.circuitbreaker.backends.catalog-service.sliding-window-type=COUNT_BASED 49 | resilience4j.circuitbreaker.backends.catalog-service.sliding-window-size=6 50 | resilience4j.circuitbreaker.backends.catalog-service.minimum-number-of-calls=4 51 | resilience4j.circuitbreaker.backends.catalog-service.wait-duration-in-open-state=20s 52 | resilience4j.circuitbreaker.backends.catalog-service.permitted-number-of-calls-in-half-open-state=2 53 | resilience4j.circuitbreaker.backends.catalog-service.failure-rate-threshold=50 54 | -------------------------------------------------------------------------------- /order-service/src/main/resources/db/migration/V1__create_order_tables.sql: -------------------------------------------------------------------------------- 1 | create sequence order_id_seq start with 1 increment by 50; 2 | create sequence order_item_id_seq start with 1 increment by 50; 3 | 4 | create table orders 5 | ( 6 | id bigint default nextval('order_id_seq') not null, 7 | order_number text not null unique, 8 | username text not null, 9 | customer_name text not null, 10 | customer_email text not null, 11 | customer_phone text not null, 12 | delivery_address_line1 text not null, 13 | delivery_address_line2 text, 14 | delivery_address_city text not null, 15 | delivery_address_state text not null, 16 | delivery_address_zip_code text not null, 17 | delivery_address_country text not null, 18 | status text not null, 19 | comments text, 20 | created_at timestamp, 21 | updated_at timestamp, 22 | primary key (id) 23 | ); 24 | 25 | create table order_items 26 | ( 27 | id bigint default nextval('order_item_id_seq') not null, 28 | code text not null, 29 | name text not null, 30 | price numeric not null, 31 | quantity integer not null, 32 | primary key (id), 33 | order_id bigint not null references orders (id) 34 | ); 35 | -------------------------------------------------------------------------------- /order-service/src/main/resources/db/migration/V2__create_order_events_table.sql: -------------------------------------------------------------------------------- 1 | create sequence order_event_id_seq start with 1 increment by 50; 2 | 3 | create table order_events 4 | ( 5 | id bigint default nextval('order_event_id_seq') not null, 6 | order_number text not null references orders (order_number), 7 | event_id text not null unique, 8 | event_type text not null, 9 | payload text not null, 10 | created_at timestamp not null, 11 | updated_at timestamp, 12 | primary key (id) 13 | ); 14 | -------------------------------------------------------------------------------- /order-service/src/main/resources/db/migration/V3__add_shedlock_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE shedlock( 2 | name VARCHAR(64) NOT NULL, 3 | lock_until TIMESTAMP NOT NULL, 4 | locked_at TIMESTAMP NOT NULL, 5 | locked_by VARCHAR(255) NOT NULL, 6 | PRIMARY KEY (name) 7 | ); -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/AbstractIT.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import static com.github.tomakehurst.wiremock.client.WireMock.*; 4 | import static java.util.Collections.singletonList; 5 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 6 | 7 | import com.fasterxml.jackson.annotation.JsonProperty; 8 | import com.github.tomakehurst.wiremock.client.WireMock; 9 | import io.restassured.RestAssured; 10 | import java.math.BigDecimal; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.keycloak.OAuth2Constants; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; 15 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 16 | import org.springframework.boot.test.context.SpringBootTest; 17 | import org.springframework.boot.test.web.server.LocalServerPort; 18 | import org.springframework.context.annotation.Import; 19 | import org.springframework.http.HttpEntity; 20 | import org.springframework.http.HttpHeaders; 21 | import org.springframework.http.MediaType; 22 | import org.springframework.test.web.servlet.MockMvc; 23 | import org.springframework.util.LinkedMultiValueMap; 24 | import org.springframework.util.MultiValueMap; 25 | import org.springframework.web.client.RestTemplate; 26 | 27 | @SpringBootTest(webEnvironment = RANDOM_PORT) 28 | @Import(ContainersConfig.class) 29 | @AutoConfigureMockMvc 30 | public abstract class AbstractIT { 31 | static final String CLIENT_ID = "bookstore-webapp"; 32 | static final String CLIENT_SECRET = "P1sibsIrELBhmvK18BOzw1bUl96DcP2z"; 33 | static final String USERNAME = "siva"; 34 | static final String PASSWORD = "siva1234"; 35 | 36 | @Autowired 37 | OAuth2ResourceServerProperties oAuth2ResourceServerProperties; 38 | 39 | @LocalServerPort 40 | int port; 41 | 42 | @Autowired 43 | protected MockMvc mockMvc; 44 | 45 | @BeforeEach 46 | void setUp() { 47 | RestAssured.port = port; 48 | } 49 | 50 | protected static void mockGetProductByCode(String code, String name, BigDecimal price) { 51 | stubFor(WireMock.get(urlMatching("/api/products/" + code)) 52 | .willReturn(aResponse() 53 | .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) 54 | .withStatus(200) 55 | .withBody( 56 | """ 57 | { 58 | "code": "%s", 59 | "name": "%s", 60 | "price": %f 61 | } 62 | """ 63 | .formatted(code, name, price.doubleValue())))); 64 | } 65 | 66 | protected String getToken() { 67 | RestTemplate restTemplate = new RestTemplate(); 68 | HttpHeaders httpHeaders = new HttpHeaders(); 69 | httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); 70 | 71 | MultiValueMap map = new LinkedMultiValueMap<>(); 72 | map.put(OAuth2Constants.GRANT_TYPE, singletonList(OAuth2Constants.PASSWORD)); 73 | map.put(OAuth2Constants.CLIENT_ID, singletonList(CLIENT_ID)); 74 | map.put(OAuth2Constants.CLIENT_SECRET, singletonList(CLIENT_SECRET)); 75 | map.put(OAuth2Constants.USERNAME, singletonList(USERNAME)); 76 | map.put(OAuth2Constants.PASSWORD, singletonList(PASSWORD)); 77 | 78 | String authServerUrl = 79 | oAuth2ResourceServerProperties.getJwt().getIssuerUri() + "/protocol/openid-connect/token"; 80 | 81 | var request = new HttpEntity<>(map, httpHeaders); 82 | KeyCloakToken token = restTemplate.postForObject(authServerUrl, request, KeyCloakToken.class); 83 | 84 | assert token != null; 85 | return token.accessToken(); 86 | } 87 | 88 | record KeyCloakToken(@JsonProperty("access_token") String accessToken) {} 89 | } 90 | -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/ContainersConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import static com.github.tomakehurst.wiremock.client.WireMock.configureFor; 4 | 5 | import dasniko.testcontainers.keycloak.KeycloakContainer; 6 | import org.springframework.boot.test.context.TestConfiguration; 7 | import org.springframework.boot.testcontainers.service.connection.ServiceConnection; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.test.context.DynamicPropertyRegistrar; 10 | import org.testcontainers.containers.PostgreSQLContainer; 11 | import org.testcontainers.containers.RabbitMQContainer; 12 | import org.testcontainers.utility.DockerImageName; 13 | import org.wiremock.integrations.testcontainers.WireMockContainer; 14 | 15 | @TestConfiguration(proxyBeanMethods = false) 16 | public class ContainersConfig { 17 | static String KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:26.0"; 18 | static String realmImportFile = "/test-bookstore-realm.json"; 19 | static String realmName = "bookstore"; 20 | 21 | static WireMockContainer wiremockServer = new WireMockContainer("wiremock/wiremock:3.5.2-alpine"); 22 | 23 | @Bean 24 | WireMockContainer wiremockServer() { 25 | wiremockServer.start(); 26 | configureFor(wiremockServer.getHost(), wiremockServer.getPort()); 27 | return wiremockServer; 28 | } 29 | 30 | @Bean 31 | @ServiceConnection 32 | PostgreSQLContainer postgresContainer() { 33 | return new PostgreSQLContainer<>(DockerImageName.parse("postgres:17-alpine")); 34 | } 35 | 36 | @Bean 37 | @ServiceConnection 38 | RabbitMQContainer rabbitContainer() { 39 | return new RabbitMQContainer(DockerImageName.parse("rabbitmq:4.0.4-alpine")); 40 | } 41 | 42 | @Bean 43 | KeycloakContainer keycloak() { 44 | return new KeycloakContainer(KEYCLOAK_IMAGE).withRealmImportFile(realmImportFile); 45 | } 46 | 47 | @Bean 48 | DynamicPropertyRegistrar dynamicPropertyRegistrar(WireMockContainer wiremockServer, KeycloakContainer keycloak) { 49 | return (registry) -> { 50 | registry.add("orders.catalog-service-url", wiremockServer::getBaseUrl); 51 | registry.add( 52 | "spring.security.oauth2.resourceserver.jwt.issuer-uri", 53 | () -> keycloak.getAuthServerUrl() + "/realms/" + realmName); 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/MockOAuth2UserContextFactory.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import java.time.Instant; 4 | import java.util.ArrayList; 5 | import java.util.List; 6 | import java.util.Map; 7 | import org.springframework.security.core.Authentication; 8 | import org.springframework.security.core.GrantedAuthority; 9 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 10 | import org.springframework.security.core.context.SecurityContext; 11 | import org.springframework.security.core.context.SecurityContextHolder; 12 | import org.springframework.security.oauth2.jwt.Jwt; 13 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; 14 | import org.springframework.security.test.context.support.WithSecurityContextFactory; 15 | import org.springframework.util.StringUtils; 16 | 17 | public class MockOAuth2UserContextFactory implements WithSecurityContextFactory { 18 | 19 | public SecurityContext createSecurityContext(WithMockOAuth2User withUser) { 20 | String username = StringUtils.hasLength(withUser.username()) ? withUser.username() : withUser.value(); 21 | if (username == null) { 22 | throw new IllegalArgumentException( 23 | withUser + " cannot have null username on both username and value properties"); 24 | } 25 | 26 | List authorities = new ArrayList<>(); 27 | for (String role : withUser.roles()) { 28 | if (role.startsWith("ROLE_")) { 29 | throw new IllegalArgumentException("roles cannot start with ROLE_ Got " + role); 30 | } 31 | authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); 32 | } 33 | 34 | Map claims = 35 | Map.of("preferred_username", username, "userId", withUser.id(), "realm_access", authorities); 36 | Map headers = Map.of("header", "mock"); 37 | Jwt jwt = new Jwt("mock-jwt-token", Instant.now(), Instant.now().plusSeconds(300), headers, claims); 38 | Authentication authentication = new JwtAuthenticationToken(jwt, authorities); 39 | SecurityContext context = SecurityContextHolder.createEmptyContext(); 40 | context.setAuthentication(authentication); 41 | return context; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/OrderServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | class OrderServiceApplicationTests extends AbstractIT { 6 | 7 | @Test 8 | void contextLoads() {} 9 | } 10 | -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/TestOrderServiceApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | 5 | public class TestOrderServiceApplication { 6 | 7 | public static void main(String[] args) { 8 | SpringApplication.from(OrderServiceApplication::main) 9 | .with(ContainersConfig.class) 10 | .run(args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/WithMockOAuth2User.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import java.lang.annotation.Documented; 4 | import java.lang.annotation.ElementType; 5 | import java.lang.annotation.Inherited; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.RetentionPolicy; 8 | import java.lang.annotation.Target; 9 | import org.springframework.security.test.context.support.WithSecurityContext; 10 | 11 | @Target({ElementType.METHOD, ElementType.TYPE}) 12 | @Retention(RetentionPolicy.RUNTIME) 13 | @Inherited 14 | @Documented 15 | @WithSecurityContext(factory = MockOAuth2UserContextFactory.class) 16 | public @interface WithMockOAuth2User { 17 | 18 | long id() default -1; 19 | 20 | String value() default "user"; 21 | 22 | String username() default ""; 23 | 24 | String[] roles() default {"USER"}; 25 | } 26 | -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/testdata/TestDataFactory.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.testdata; 2 | 3 | import static org.instancio.Select.field; 4 | 5 | import com.sivalabs.bookstore.orders.domain.models.Address; 6 | import com.sivalabs.bookstore.orders.domain.models.CreateOrderRequest; 7 | import com.sivalabs.bookstore.orders.domain.models.Customer; 8 | import com.sivalabs.bookstore.orders.domain.models.OrderItem; 9 | import java.math.BigDecimal; 10 | import java.util.List; 11 | import java.util.Set; 12 | import org.instancio.Instancio; 13 | 14 | public class TestDataFactory { 15 | static final List VALID_COUNTIES = List.of("India", "Germany"); 16 | static final Set VALID_ORDER_ITEMS = 17 | Set.of(new OrderItem("P100", "Product 1", new BigDecimal("25.50"), 1)); 18 | static final Set INVALID_ORDER_ITEMS = 19 | Set.of(new OrderItem("ABCD", "Product 1", new BigDecimal("25.50"), 1)); 20 | 21 | public static CreateOrderRequest createValidOrderRequest() { 22 | return Instancio.of(CreateOrderRequest.class) 23 | .generate(field(Customer::email), gen -> gen.text().pattern("#a#a#a#a#a#a@mail.com")) 24 | .set(field(CreateOrderRequest::items), VALID_ORDER_ITEMS) 25 | .generate(field(Address::country), gen -> gen.oneOf(VALID_COUNTIES)) 26 | .create(); 27 | } 28 | 29 | public static CreateOrderRequest createOrderRequestWithInvalidCustomer() { 30 | return Instancio.of(CreateOrderRequest.class) 31 | .generate(field(Customer::email), gen -> gen.text().pattern("#c#c#c#c#d#d@mail.com")) 32 | .set(field(Customer::phone), "") 33 | .generate(field(Address::country), gen -> gen.oneOf(VALID_COUNTIES)) 34 | .set(field(CreateOrderRequest::items), VALID_ORDER_ITEMS) 35 | .create(); 36 | } 37 | 38 | public static CreateOrderRequest createOrderRequestWithInvalidDeliveryAddress() { 39 | return Instancio.of(CreateOrderRequest.class) 40 | .generate(field(Customer::email), gen -> gen.text().pattern("#c#c#c#c#d#d@mail.com")) 41 | .set(field(Address::country), "") 42 | .set(field(CreateOrderRequest::items), VALID_ORDER_ITEMS) 43 | .create(); 44 | } 45 | 46 | public static CreateOrderRequest createOrderRequestWithNoItems() { 47 | return Instancio.of(CreateOrderRequest.class) 48 | .generate(field(Customer::email), gen -> gen.text().pattern("#c#c#c#c#d#d@mail.com")) 49 | .generate(field(Address::country), gen -> gen.oneOf(VALID_COUNTIES)) 50 | .set(field(CreateOrderRequest::items), Set.of()) 51 | .create(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/web/controllers/GetOrdersTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web.controllers; 2 | 3 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 4 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 5 | 6 | import com.sivalabs.bookstore.orders.AbstractIT; 7 | import com.sivalabs.bookstore.orders.WithMockOAuth2User; 8 | import org.junit.jupiter.api.Test; 9 | 10 | class GetOrdersTests extends AbstractIT { 11 | 12 | @Test 13 | @WithMockOAuth2User(username = "user") 14 | void shouldGetOrdersSuccessfully() throws Exception { 15 | mockMvc.perform(get("/api/orders")).andExpect(status().isOk()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/web/controllers/OrderControllerTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web.controllers; 2 | 3 | import static io.restassured.RestAssured.given; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | import static org.hamcrest.CoreMatchers.is; 6 | import static org.hamcrest.CoreMatchers.notNullValue; 7 | 8 | import com.sivalabs.bookstore.orders.AbstractIT; 9 | import com.sivalabs.bookstore.orders.domain.models.OrderSummary; 10 | import com.sivalabs.bookstore.orders.testdata.TestDataFactory; 11 | import io.restassured.common.mapper.TypeRef; 12 | import io.restassured.http.ContentType; 13 | import java.math.BigDecimal; 14 | import java.util.List; 15 | import org.junit.jupiter.api.Nested; 16 | import org.junit.jupiter.api.Test; 17 | import org.springframework.http.HttpStatus; 18 | import org.springframework.test.context.jdbc.Sql; 19 | 20 | @Sql("/test-orders.sql") 21 | class OrderControllerTests extends AbstractIT { 22 | 23 | @Nested 24 | class CreateOrderTests { 25 | @Test 26 | void shouldCreateOrderSuccessfully() { 27 | mockGetProductByCode("P100", "Product 1", new BigDecimal("25.50")); 28 | var payload = 29 | """ 30 | { 31 | "customer" : { 32 | "name": "Siva", 33 | "email": "siva@gmail.com", 34 | "phone": "999999999" 35 | }, 36 | "deliveryAddress" : { 37 | "addressLine1": "HNO 123", 38 | "addressLine2": "Kukatpally", 39 | "city": "Hyderabad", 40 | "state": "Telangana", 41 | "zipCode": "500072", 42 | "country": "India" 43 | }, 44 | "items": [ 45 | { 46 | "code": "P100", 47 | "name": "Product 1", 48 | "price": 25.50, 49 | "quantity": 1 50 | } 51 | ] 52 | } 53 | """; 54 | given().contentType(ContentType.JSON) 55 | .header("Authorization", "Bearer " + getToken()) 56 | .body(payload) 57 | .when() 58 | .post("/api/orders") 59 | .then() 60 | .statusCode(HttpStatus.CREATED.value()) 61 | .body("orderNumber", notNullValue()); 62 | } 63 | 64 | @Test 65 | void shouldReturnBadRequestWhenMandatoryDataIsMissing() { 66 | var payload = TestDataFactory.createOrderRequestWithInvalidCustomer(); 67 | given().contentType(ContentType.JSON) 68 | .header("Authorization", "Bearer " + getToken()) 69 | .body(payload) 70 | .when() 71 | .post("/api/orders") 72 | .then() 73 | .statusCode(HttpStatus.BAD_REQUEST.value()); 74 | } 75 | } 76 | 77 | @Nested 78 | class GetOrdersTests { 79 | @Test 80 | void shouldGetOrdersSuccessfully() { 81 | List orderSummaries = given().when() 82 | .header("Authorization", "Bearer " + getToken()) 83 | .get("/api/orders") 84 | .then() 85 | .statusCode(200) 86 | .extract() 87 | .body() 88 | .as(new TypeRef<>() {}); 89 | 90 | assertThat(orderSummaries).hasSize(2); 91 | } 92 | } 93 | 94 | @Nested 95 | class GetOrderByOrderNumberTests { 96 | String orderNumber = "order-123"; 97 | 98 | @Test 99 | void shouldGetOrderSuccessfully() { 100 | given().when() 101 | .header("Authorization", "Bearer " + getToken()) 102 | .get("/api/orders/{orderNumber}", orderNumber) 103 | .then() 104 | .statusCode(200) 105 | .body("orderNumber", is(orderNumber)) 106 | .body("items.size()", is(2)); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /order-service/src/test/java/com/sivalabs/bookstore/orders/web/controllers/OrderControllerUnitTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web.controllers; 2 | 3 | import static com.sivalabs.bookstore.orders.testdata.TestDataFactory.createOrderRequestWithInvalidCustomer; 4 | import static com.sivalabs.bookstore.orders.testdata.TestDataFactory.createOrderRequestWithInvalidDeliveryAddress; 5 | import static com.sivalabs.bookstore.orders.testdata.TestDataFactory.createOrderRequestWithNoItems; 6 | import static org.junit.jupiter.api.Named.named; 7 | import static org.junit.jupiter.params.provider.Arguments.arguments; 8 | import static org.mockito.ArgumentMatchers.any; 9 | import static org.mockito.ArgumentMatchers.eq; 10 | import static org.mockito.BDDMockito.given; 11 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 12 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 13 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 14 | 15 | import com.fasterxml.jackson.databind.ObjectMapper; 16 | import com.sivalabs.bookstore.orders.domain.OrderService; 17 | import com.sivalabs.bookstore.orders.domain.SecurityService; 18 | import com.sivalabs.bookstore.orders.domain.models.CreateOrderRequest; 19 | import java.util.stream.Stream; 20 | import org.junit.jupiter.api.BeforeEach; 21 | import org.junit.jupiter.params.ParameterizedTest; 22 | import org.junit.jupiter.params.provider.Arguments; 23 | import org.junit.jupiter.params.provider.MethodSource; 24 | import org.springframework.beans.factory.annotation.Autowired; 25 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 26 | import org.springframework.http.MediaType; 27 | import org.springframework.security.test.context.support.WithMockUser; 28 | import org.springframework.test.context.bean.override.mockito.MockitoBean; 29 | import org.springframework.test.web.servlet.MockMvc; 30 | 31 | @WebMvcTest(OrderController.class) 32 | class OrderControllerUnitTests { 33 | @MockitoBean 34 | private OrderService orderService; 35 | 36 | @MockitoBean 37 | private SecurityService securityService; 38 | 39 | @Autowired 40 | private MockMvc mockMvc; 41 | 42 | @Autowired 43 | private ObjectMapper objectMapper; 44 | 45 | @BeforeEach 46 | void setUp() { 47 | given(securityService.getLoginUserName()).willReturn("siva"); 48 | } 49 | 50 | @ParameterizedTest(name = "[{index}]-{0}") 51 | @MethodSource("createOrderRequestProvider") 52 | @WithMockUser 53 | void shouldReturnBadRequestWhenOrderPayloadIsInvalid(CreateOrderRequest request) throws Exception { 54 | given(orderService.createOrder(eq("siva"), any(CreateOrderRequest.class))) 55 | .willReturn(null); 56 | 57 | mockMvc.perform(post("/api/orders") 58 | .with(csrf()) 59 | .contentType(MediaType.APPLICATION_JSON) 60 | .content(objectMapper.writeValueAsString(request))) 61 | .andExpect(status().isBadRequest()); 62 | } 63 | 64 | static Stream createOrderRequestProvider() { 65 | return Stream.of( 66 | arguments(named("Order with Invalid Customer", createOrderRequestWithInvalidCustomer())), 67 | arguments(named("Order with Invalid Delivery Address", createOrderRequestWithInvalidDeliveryAddress())), 68 | arguments(named("Order with No Items", createOrderRequestWithNoItems()))); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /order-service/src/test/resources/test-orders.sql: -------------------------------------------------------------------------------- 1 | truncate table orders cascade; 2 | alter sequence order_id_seq restart with 100; 3 | alter sequence order_item_id_seq restart with 100; 4 | 5 | insert into orders (id,order_number,username, 6 | customer_name,customer_email,customer_phone, 7 | delivery_address_line1,delivery_address_line2,delivery_address_city, 8 | delivery_address_state,delivery_address_zip_code,delivery_address_country, 9 | status,comments) values 10 | (1, 'order-123', 'siva', 'Siva', 'siva@gmail.com', '11111111', '123 Main St', 'Apt 1', 'Dallas', 'TX', '75001', 'USA', 'NEW', null), 11 | (2, 'order-456', 'siva', 'Prasad', 'prasad@gmail.com', '2222222', '123 Main St', 'Apt 1', 'Hyderabad', 'TS', '500072', 'India', 'NEW', null) 12 | ; 13 | 14 | insert into order_items(order_id, code, name, price, quantity) values 15 | (1, 'P100', 'The Hunger Games', 34.0, 2), 16 | (1, 'P101', 'To Kill a Mockingbird', 45.40, 1), 17 | (2, 'P102', 'The Chronicles of Narnia', 44.50, 1) 18 | ; 19 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | com.sivalabs 8 | spring-boot-microservices-course 9 | 0.0.1-SNAPSHOT 10 | pom 11 | spring-boot-microservices-course 12 | 13 | 14 | UTF-8 15 | 21 16 | ${java.version} 17 | 2.44.4 18 | 19 | 20 | 21 | catalog-service 22 | order-service 23 | notification-service 24 | api-gateway 25 | bookstore-webapp 26 | 27 | 28 | 29 | 30 | 31 | com.diffplug.spotless 32 | spotless-maven-plugin 33 | ${spotless-maven-plugin.version} 34 | 35 | 36 | 37 | 38 | 39 | 2.35.0 40 | 41 | 42 | 43 | 44 | 45 | 46 | compile 47 | 48 | check 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "schedule:earlyMondays" 6 | ] 7 | } 8 | --------------------------------------------------------------------------------