├── .sdkmanrc ├── src ├── main │ ├── resources │ │ ├── static │ │ │ ├── css │ │ │ │ └── styles.css │ │ │ └── images │ │ │ │ └── books.png │ │ ├── db │ │ │ └── migration │ │ │ │ ├── __root │ │ │ │ └── V1__create_events_schema.sql │ │ │ │ ├── orders │ │ │ │ ├── V1__create_orders_schema.sql │ │ │ │ ├── V4__orders_add_user_id_to_orders_table.sql │ │ │ │ ├── V3__orders_add_orders_data.sql │ │ │ │ └── V2__orders_create_orders_table.sql │ │ │ │ ├── users │ │ │ │ ├── V1__create_users_schema.sql │ │ │ │ ├── V3__users_add_users_data.sql │ │ │ │ └── V2__create_users_table.sql │ │ │ │ ├── catalog │ │ │ │ ├── V1__create_catalog_schema.sql │ │ │ │ ├── V2__catalog_create_products_table.sql │ │ │ │ └── V3__catalog_add_books_data.sql │ │ │ │ └── inventory │ │ │ │ ├── V1__create_inventory_schema.sql │ │ │ │ └── V2__inventory_create_inventory_table.sql │ │ ├── templates │ │ │ ├── partials │ │ │ │ ├── orders.html │ │ │ │ ├── pagination.html │ │ │ │ ├── products.html │ │ │ │ ├── cart.html │ │ │ │ └── order-form.html │ │ │ ├── products.html │ │ │ ├── registration-success.html │ │ │ ├── cart.html │ │ │ ├── orders.html │ │ │ ├── login.html │ │ │ ├── registration.html │ │ │ ├── layout.html │ │ │ └── order_details.html │ │ ├── certs │ │ │ ├── public.pem │ │ │ ├── keypair.pem │ │ │ └── private.pem │ │ └── application.properties │ └── java │ │ └── com │ │ └── sivalabs │ │ └── bookstore │ │ ├── config │ │ ├── package-info.java │ │ ├── WebMvcConfig.java │ │ ├── CommonSecurityConfig.java │ │ ├── OpenApiConfig.java │ │ ├── RabbitMQConfig.java │ │ ├── WebSecurityConfig.java │ │ ├── JwtSecurityConfig.java │ │ └── ApiSecurityConfig.java │ │ ├── users │ │ ├── package-info.java │ │ ├── domain │ │ │ ├── CreateUserCmd.java │ │ │ ├── UserDto.java │ │ │ ├── JwtToken.java │ │ │ ├── UserMapper.java │ │ │ ├── UserRepository.java │ │ │ ├── Role.java │ │ │ ├── UserService.java │ │ │ ├── SecurityUserDetailsService.java │ │ │ ├── JwtTokenHelper.java │ │ │ ├── SecurityUser.java │ │ │ └── UserEntity.java │ │ ├── UserContextUtils.java │ │ └── web │ │ │ ├── UserController.java │ │ │ └── UserRestController.java │ │ ├── catalog │ │ ├── package-info.java │ │ ├── domain │ │ │ ├── ProductRepository.java │ │ │ ├── ProductNotFoundException.java │ │ │ ├── ProductService.java │ │ │ └── ProductEntity.java │ │ ├── ProductDto.java │ │ ├── mappers │ │ │ └── ProductMapper.java │ │ ├── ProductApi.java │ │ └── web │ │ │ ├── CatalogExceptionHandler.java │ │ │ ├── ProductWebController.java │ │ │ └── ProductRestController.java │ │ ├── orders │ │ ├── CreateOrderResponse.java │ │ ├── domain │ │ │ ├── models │ │ │ │ ├── package-info.java │ │ │ │ ├── OrderStatus.java │ │ │ │ ├── OrderCreatedEvent.java │ │ │ │ ├── Customer.java │ │ │ │ └── OrderItem.java │ │ │ ├── OrderRepository.java │ │ │ ├── ProductServiceClient.java │ │ │ ├── OrderService.java │ │ │ └── OrderEntity.java │ │ ├── InvalidOrderException.java │ │ ├── package-info.java │ │ ├── OrderView.java │ │ ├── web │ │ │ ├── OrderForm.java │ │ │ ├── CartUtil.java │ │ │ ├── OrdersExceptionHandler.java │ │ │ ├── Cart.java │ │ │ ├── CartController.java │ │ │ ├── OrderRestController.java │ │ │ └── OrderWebController.java │ │ ├── OrderNotFoundException.java │ │ ├── OrderDto.java │ │ ├── CreateOrderRequest.java │ │ ├── OrdersApi.java │ │ └── mappers │ │ │ └── OrderMapper.java │ │ ├── inventory │ │ ├── package-info.java │ │ ├── InventoryRepository.java │ │ ├── OrderEventInventoryHandler.java │ │ ├── InventoryEntity.java │ │ └── InventoryService.java │ │ ├── notifications │ │ ├── package-info.java │ │ └── OrderEventNotificationHandler.java │ │ ├── common │ │ ├── package-info.java │ │ └── models │ │ │ └── PagedResult.java │ │ ├── BookStoreApplication.java │ │ └── ApplicationProperties.java └── test │ ├── java │ └── com │ │ └── sivalabs │ │ └── bookstore │ │ ├── TestBookStoreApplication.java │ │ ├── BookStoreApplicationTests.java │ │ ├── ModularityTests.java │ │ ├── inventory │ │ └── InventoryIntegrationTests.java │ │ ├── TestcontainersConfiguration.java │ │ ├── catalog │ │ └── web │ │ │ └── ProductRestControllerTests.java │ │ ├── users │ │ └── web │ │ │ └── UserRestControllerTests.java │ │ └── orders │ │ └── web │ │ └── OrderRestControllerTests.java │ └── resources │ └── test-products-data.sql ├── docs └── bookstore-modulith.png ├── renovate.json ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── .github ├── dependabot.yml └── workflows │ └── maven.yml ├── http_requests.http ├── k8s ├── kind │ ├── kind-config.yml │ ├── kind-cluster.sh │ └── kind-cluster.ps1 ├── manifests │ ├── zipkin.yaml │ ├── postgres.yaml │ ├── rabbitmq.yaml │ └── app.yaml ├── check-ready.sh └── check-ready.ps1 ├── .gitignore ├── compose.yml ├── Taskfile.yml ├── README.md ├── mvnw.cmd ├── LICENSE └── mvnw /.sdkmanrc: -------------------------------------------------------------------------------- 1 | java=25-tem 2 | maven=3.9.11 3 | -------------------------------------------------------------------------------- /src/main/resources/static/css/styles.css: -------------------------------------------------------------------------------- 1 | #app { 2 | padding-top: 90px; 3 | } -------------------------------------------------------------------------------- /src/main/resources/db/migration/__root/V1__create_events_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA events; 2 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/orders/V1__create_orders_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA orders; 2 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/users/V1__create_users_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA users; 2 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/catalog/V1__create_catalog_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA catalog; 2 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/inventory/V1__create_inventory_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA inventory; 2 | -------------------------------------------------------------------------------- /docs/bookstore-modulith.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-modular-monolith/HEAD/docs/bookstore-modulith.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/main/resources/static/images/books.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sivaprasadreddy/spring-modular-monolith/HEAD/src/main/resources/static/images/books.png -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/config/package-info.java: -------------------------------------------------------------------------------- 1 | @NullMarked 2 | package com.sivalabs.bookstore.config; 3 | 4 | import org.jspecify.annotations.NullMarked; 5 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/package-info.java: -------------------------------------------------------------------------------- 1 | @NullMarked 2 | package com.sivalabs.bookstore.users; 3 | 4 | import org.jspecify.annotations.NullMarked; 5 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/catalog/package-info.java: -------------------------------------------------------------------------------- 1 | @NullMarked 2 | package com.sivalabs.bookstore.catalog; 3 | 4 | import org.jspecify.annotations.NullMarked; 5 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/CreateOrderResponse.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | public record CreateOrderResponse(String orderNumber) {} 4 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/inventory/package-info.java: -------------------------------------------------------------------------------- 1 | @NullMarked 2 | package com.sivalabs.bookstore.inventory; 3 | 4 | import org.jspecify.annotations.NullMarked; 5 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/notifications/package-info.java: -------------------------------------------------------------------------------- 1 | @NullMarked 2 | package com.sivalabs.bookstore.notifications; 3 | 4 | import org.jspecify.annotations.NullMarked; 5 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/CreateUserCmd.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | public record CreateUserCmd(String name, String email, String password, Role role) {} 4 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/UserDto.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | public record UserDto(Long id, String name, String email, String password, Role role) {} 4 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | wrapperVersion=3.3.4 2 | distributionType=only-script 3 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip 4 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/JwtToken.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | import java.time.Instant; 4 | 5 | public record JwtToken(String token, Instant expiresAt) {} 6 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/common/package-info.java: -------------------------------------------------------------------------------- 1 | @ApplicationModule(type = ApplicationModule.Type.OPEN) 2 | package com.sivalabs.bookstore.common; 3 | 4 | import org.springframework.modulith.ApplicationModule; 5 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/domain/models/package-info.java: -------------------------------------------------------------------------------- 1 | @NamedInterface("order-models") 2 | package com.sivalabs.bookstore.orders.domain.models; 3 | 4 | import org.springframework.modulith.NamedInterface; 5 | -------------------------------------------------------------------------------- /src/main/resources/templates/partials/orders.html: -------------------------------------------------------------------------------- 1 | 2 | OrderNumber 3 | status 4 | 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/orders/V4__orders_add_user_id_to_orders_table.sql: -------------------------------------------------------------------------------- 1 | SET search_path TO orders; 2 | 3 | ALTER TABLE orders ADD COLUMN user_id BIGINT; 4 | UPDATE orders SET user_id = 2 WHERE user_id IS NULL; 5 | ALTER TABLE orders ALTER COLUMN user_id SET NOT NULL; 6 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/InvalidOrderException.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | public class InvalidOrderException extends RuntimeException { 4 | 5 | public InvalidOrderException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/package-info.java: -------------------------------------------------------------------------------- 1 | @ApplicationModule(allowedDependencies = {"catalog", "users"}) 2 | @NullMarked 3 | package com.sivalabs.bookstore.orders; 4 | 5 | import org.jspecify.annotations.NullMarked; 6 | import org.springframework.modulith.ApplicationModule; 7 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/OrderView.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.Customer; 4 | import com.sivalabs.bookstore.orders.domain.models.OrderStatus; 5 | 6 | public record OrderView(String orderNumber, OrderStatus status, Customer customer) {} 7 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/UserMapper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | public class UserMapper { 4 | public static UserDto toUser(UserEntity entity) { 5 | return new UserDto(entity.getId(), entity.getName(), entity.getEmail(), entity.getPassword(), entity.getRole()); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/domain/models/OrderCreatedEvent.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain.models; 2 | 3 | import org.springframework.modulith.events.Externalized; 4 | 5 | @Externalized("BookStoreExchange::orders.new") 6 | public record OrderCreatedEvent(String orderNumber, String productCode, int quantity, Customer customer) {} 7 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | import java.util.Optional; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface UserRepository extends JpaRepository { 7 | Optional findByEmailIgnoreCase(String email); 8 | } 9 | -------------------------------------------------------------------------------- /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 | public interface ProductRepository extends JpaRepository { 7 | Optional findByCode(String code); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/inventory/InventoryRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.inventory; 2 | 3 | import java.util.Optional; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | interface InventoryRepository extends JpaRepository { 7 | Optional findByProductCode(String productCode); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/users/V3__users_add_users_data.sql: -------------------------------------------------------------------------------- 1 | SET search_path TO users; 2 | 3 | insert into users(id, email, password, name, role) values 4 | (1,'admin@gmail.com','$2a$10$hKDVYxLefVHV/vtuPhWD3OigtRyOykRLDdUAp80Z1crSoS1lFqaFS','SivaLabs', 'ROLE_ADMIN'), 5 | (2,'siva@gmail.com','$2a$10$UFEPYW7Rx1qZqdHajzOnB.VBR3rvm7OI7uSix4RadfQiNhkZOi2fi','Siva', 'ROLE_USER'); 6 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/Role.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | import org.springframework.modulith.NamedInterface; 4 | 5 | @NamedInterface 6 | public enum Role { 7 | ROLE_USER, 8 | ROLE_ADMIN; 9 | 10 | public static String getRoleHierarchy() { 11 | return ROLE_ADMIN.name() + " > " + ROLE_USER.name(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/resources/templates/products.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 |
9 |
10 |
11 | 12 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/web/OrderForm.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.Customer; 4 | import jakarta.validation.Valid; 5 | import jakarta.validation.constraints.NotEmpty; 6 | 7 | public record OrderForm( 8 | @Valid Customer customer, 9 | @NotEmpty(message = "Delivery address is required") String deliveryAddress) {} 10 | -------------------------------------------------------------------------------- /src/test/java/com/sivalabs/bookstore/TestBookStoreApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | 5 | public class TestBookStoreApplication { 6 | 7 | public static void main(String[] args) { 8 | SpringApplication.from(BookStoreApplication::main) 9 | .with(TestcontainersConfiguration.class) 10 | .run(args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/OrderNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 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 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/web/CartUtil.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web; 2 | 3 | import jakarta.servlet.http.HttpSession; 4 | 5 | public final class CartUtil { 6 | public static Cart getCart(HttpSession session) { 7 | Cart cart = (Cart) session.getAttribute("cart"); 8 | if (cart == null) { 9 | cart = new Cart(); 10 | } 11 | return cart; 12 | } 13 | 14 | private CartUtil() {} 15 | } 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/catalog/V2__catalog_create_products_table.sql: -------------------------------------------------------------------------------- 1 | SET search_path TO catalog; 2 | 3 | create sequence product_id_seq start with 100 increment by 50; 4 | 5 | create table products 6 | ( 7 | id bigint not null default nextval('catalog.product_id_seq'), 8 | code text not null unique, 9 | name text not null, 10 | image_url text, 11 | description text, 12 | price numeric not null, 13 | primary key (id) 14 | ); 15 | -------------------------------------------------------------------------------- /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 | 9 | @NotBlank(message = "Customer email is required") @Email String email, 10 | 11 | @NotBlank(message = "Customer Phone number is required") String phone) {} 12 | -------------------------------------------------------------------------------- /src/main/resources/certs/public.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwE7txdk+D/rF88zlA2Ec 3 | Eu1fuOceaeejIsfeEH5JYUbFWjaC7E+XCaivDjqEPpQP95+TX+cDpIuZ9efg6uGz 4 | H7eWzsGvJiUP8UkiwV031uSz9qDG5WodoHqhrvZ4exZeKFcEi5hSBDCbqTSaa7cg 5 | CrEKsF4oQqNhbOVvNG0Pk6zu2yNVUWNxhMqAKEuATD+iZk1S6oDKeTNuSk6bR9W1 6 | fRzgF1emALZrwezVC4FkX0vaIk87WCcdhatII1us0qqkBXvLOlyF2Fs3sLnLbR++ 7 | 49pD1t7Q2QnumBrWdJ2Pv/Qezl3LQcbSO5yefzsXpvwx07C5Jg62ixnN4lJZwEzf 8 | TQIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/catalog/ProductDto.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import java.math.BigDecimal; 5 | 6 | public record ProductDto(String code, String name, String description, String imageUrl, BigDecimal price) { 7 | @JsonIgnore 8 | public String getDisplayName() { 9 | if (name.length() <= 20) { 10 | return name; 11 | } 12 | return name.substring(0, 20) + "..."; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/BookStoreApplication.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore; 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 BookStoreApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(BookStoreApplication.class, args); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/resources/templates/registration-success.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | Registration Successful 8 | 9 | 10 |
11 |

Registration is successful

12 |

Click here to Login

13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/test/java/com/sivalabs/bookstore/BookStoreApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore; 2 | 3 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 4 | 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.context.annotation.Import; 8 | 9 | @SpringBootTest(webEnvironment = RANDOM_PORT) 10 | @Import(TestcontainersConfiguration.class) 11 | class BookStoreApplicationTests { 12 | 13 | @Test 14 | void contextLoads() {} 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/catalog/mappers/ProductMapper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.mappers; 2 | 3 | import com.sivalabs.bookstore.catalog.ProductDto; 4 | import com.sivalabs.bookstore.catalog.domain.ProductEntity; 5 | import org.springframework.stereotype.Component; 6 | 7 | @Component 8 | public class ProductMapper { 9 | 10 | public ProductDto mapToDto(ProductEntity entity) { 11 | return new ProductDto( 12 | entity.getCode(), entity.getName(), entity.getDescription(), entity.getImageUrl(), entity.getPrice()); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /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 @Min(1) Integer quantity) {} 13 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/users/V2__create_users_table.sql: -------------------------------------------------------------------------------- 1 | SET search_path TO users; 2 | 3 | create sequence user_id_seq start with 100 increment by 50; 4 | 5 | create table users 6 | ( 7 | id bigint not null default nextval('users.user_id_seq'), 8 | email text not null, 9 | password text not null, 10 | name text not null, 11 | role text not null, 12 | created_at timestamp not null default current_timestamp, 13 | updated_at timestamp, 14 | primary key (id), 15 | constraint user_email_unique unique (email) 16 | ); -------------------------------------------------------------------------------- /http_requests.http: -------------------------------------------------------------------------------- 1 | ### Get Products 2 | GET http://localhost:8080/api/products 3 | 4 | ### Get Product by code 5 | GET http://localhost:8080/api/products/P100 6 | 7 | ### Create Order 8 | POST http://localhost:8080/api/orders 9 | Content-Type: application/json 10 | 11 | { 12 | "customer": { 13 | "name": "Siva", 14 | "email": "siva123@gmail.com", 15 | "phone": "9876523456" 16 | }, 17 | "deliveryAddress": "James, Bangalore, India", 18 | "item":{ 19 | "code": "P100", 20 | "name": "The Hunger Games", 21 | "price": 34.0, 22 | "quantity": 1 23 | } 24 | } 25 | 26 | ### 27 | 28 | -------------------------------------------------------------------------------- /src/test/java/com/sivalabs/bookstore/ModularityTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.modulith.core.ApplicationModules; 5 | import org.springframework.modulith.docs.Documenter; 6 | 7 | class ModularityTests { 8 | static ApplicationModules modules = ApplicationModules.of(BookStoreApplication.class); 9 | 10 | @Test 11 | void verifiesModularStructure() { 12 | modules.verify(); 13 | } 14 | 15 | @Test 16 | void createModuleDocumentation() { 17 | new Documenter(modules).writeDocumentation(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /k8s/kind/kind-config.yml: -------------------------------------------------------------------------------- 1 | apiVersion: kind.x-k8s.io/v1alpha4 2 | kind: Cluster 3 | name: sivalabs-k8s 4 | nodes: 5 | - role: control-plane 6 | kubeadmConfigPatches: 7 | - | 8 | kind: InitConfiguration 9 | nodeRegistration: 10 | kubeletExtraArgs: 11 | node-labels: "ingress-ready=true" 12 | extraPortMappings: 13 | - containerPort: 80 14 | hostPort: 80 15 | protocol: TCP 16 | - containerPort: 443 17 | hostPort: 443 18 | protocol: TCP 19 | - containerPort: 30090 20 | hostPort: 30090 21 | protocol: TCP 22 | - containerPort: 30091 23 | hostPort: 30091 24 | protocol: TCP 25 | - containerPort: 30092 26 | hostPort: 30092 27 | protocol: TCP -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | .gradle 6 | build/ 7 | !gradle/wrapper/gradle-wrapper.jar 8 | !**/src/main/**/build/ 9 | !**/src/test/**/build/ 10 | 11 | ### STS ### 12 | .apt_generated 13 | .classpath 14 | .factorypath 15 | .project 16 | .settings 17 | .springBeans 18 | .sts4-cache 19 | bin/ 20 | !**/src/main/**/bin/ 21 | !**/src/test/**/bin/ 22 | 23 | ### IntelliJ IDEA ### 24 | .idea 25 | *.iws 26 | *.iml 27 | *.ipr 28 | out/ 29 | !**/src/main/**/out/ 30 | !**/src/test/**/out/ 31 | 32 | ### NetBeans ### 33 | /nbproject/private/ 34 | /nbbuild/ 35 | /dist/ 36 | /nbdist/ 37 | /.nb-gradle/ 38 | 39 | ### VS Code ### 40 | .vscode/ 41 | 42 | *.log 43 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/notifications/OrderEventNotificationHandler.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.notifications; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.OrderCreatedEvent; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.modulith.events.ApplicationModuleListener; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | class OrderEventNotificationHandler { 11 | private static final Logger log = LoggerFactory.getLogger(OrderEventNotificationHandler.class); 12 | 13 | @ApplicationModuleListener 14 | void handle(OrderCreatedEvent event) { 15 | log.info("[Notification]: Received order created event: {}", event); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/inventory/V2__inventory_create_inventory_table.sql: -------------------------------------------------------------------------------- 1 | SET search_path TO inventory; 2 | 3 | create sequence inventory_id_seq start with 100 increment by 50; 4 | 5 | create table inventory 6 | ( 7 | id bigint not null default nextval('inventory.inventory_id_seq'), 8 | product_code text not null unique, 9 | quantity bigint not null, 10 | primary key (id) 11 | ); 12 | 13 | insert into inventory(product_code, quantity) values 14 | ('P100', 400), 15 | ('P101', 200), 16 | ('P102', 300), 17 | ('P103', 100), 18 | ('P104', 40), 19 | ('P105', 100), 20 | ('P106', 1400), 21 | ('P107', 4200), 22 | ('P108', 430), 23 | ('P109', 405), 24 | ('P110', 300), 25 | ('P111', 4100), 26 | ('P112', 4300), 27 | ('P113', 700), 28 | ('P114', 600) 29 | ; 30 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | name: Maven Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | - "package-by-*" 8 | paths-ignore: 9 | - '.gitignore' 10 | - '.sdkmanrc' 11 | - 'README.md' 12 | - 'LICENSE' 13 | - 'Taskfile.yml' 14 | - 'renovate.json' 15 | - 'k8s/**' 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v6 22 | 23 | - name: Setup Java 24 | uses: actions/setup-java@v5 25 | with: 26 | java-version: 25 27 | distribution: 'temurin' 28 | cache: 'maven' 29 | 30 | - name: Make Maven wrapper executable 31 | run: chmod +x mvnw 32 | 33 | - name: Build with Maven 34 | run: ./mvnw -ntp verify 35 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/catalog/ProductApi.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog; 2 | 3 | import com.sivalabs.bookstore.catalog.domain.ProductService; 4 | import com.sivalabs.bookstore.catalog.mappers.ProductMapper; 5 | import java.util.Optional; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | public class ProductApi { 10 | private final ProductService productService; 11 | private final ProductMapper productMapper; 12 | 13 | public ProductApi(ProductService productService, ProductMapper productMapper) { 14 | this.productService = productService; 15 | this.productMapper = productMapper; 16 | } 17 | 18 | public Optional getByCode(String code) { 19 | return productService.getByCode(code).map(productMapper::mapToDto); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/orders/V3__orders_add_orders_data.sql: -------------------------------------------------------------------------------- 1 | SET search_path TO orders; 2 | 3 | insert into orders(id, order_number, customer_name,customer_email,customer_phone, delivery_address, product_code, product_name, product_price, quantity, status, comments, created_at) values 4 | (1, '16f69458-2f65-49ba-8779-bdaeafc7fa70', 'Siva','siva@gmail.com', '9911122233', 'Siva, Hyderabad, India', 'P100', 'The Hunger Games', 34.0, 1, 'NEW',null, CURRENT_TIMESTAMP), 5 | (2, '594943a8-d209-40b7-958c-e1efdf72877f', 'John','john@gmail.com', '9911122888', 'Prasad, Hyderabad, India', 'P101', 'To Kill a Mockingbird', 45.40, 3, 'NEW',null, CURRENT_TIMESTAMP), 6 | (3, '748de59b-a4e7-46f1-94aa-f2faba8bb8c3', 'James','james@gmail.com', '9911122244', 'Ramu, Hyderabad, India', 'P102', 'The Chronicles of Narnia', 44.50, 2, 'NEW',null, CURRENT_TIMESTAMP) 7 | ; 8 | -------------------------------------------------------------------------------- /src/main/resources/templates/cart.html: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 |
10 |
11 | 12 | 15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/OrderDto.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import com.sivalabs.bookstore.orders.domain.models.Customer; 5 | import com.sivalabs.bookstore.orders.domain.models.OrderItem; 6 | import com.sivalabs.bookstore.orders.domain.models.OrderStatus; 7 | import java.math.BigDecimal; 8 | import java.time.LocalDateTime; 9 | 10 | public record OrderDto( 11 | String orderNumber, 12 | Long userId, 13 | OrderItem item, 14 | Customer customer, 15 | String deliveryAddress, 16 | OrderStatus status, 17 | LocalDateTime createdAt) { 18 | 19 | @JsonProperty(access = JsonProperty.Access.READ_ONLY) 20 | public BigDecimal getTotalAmount() { 21 | return item.price().multiply(BigDecimal.valueOf(item.quantity())); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/orders/V2__orders_create_orders_table.sql: -------------------------------------------------------------------------------- 1 | SET search_path TO orders; 2 | 3 | create sequence order_id_seq start with 100 increment by 50; 4 | 5 | create table orders 6 | ( 7 | id bigint not null default nextval('orders.order_id_seq'), 8 | order_number text not null unique, 9 | customer_name text not null, 10 | customer_email text not null, 11 | customer_phone text not null, 12 | delivery_address text not null, 13 | product_code text not null, 14 | product_name text not null, 15 | product_price text not null, 16 | quantity int not null, 17 | status text not null, 18 | comments text, 19 | created_at timestamp not null, 20 | updated_at timestamp, 21 | primary key (id) 22 | ); 23 | -------------------------------------------------------------------------------- /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 | 23 | 24 |
Order IDStatus
25 |
26 |
27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/domain/OrderRepository.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import java.util.List; 4 | import java.util.Optional; 5 | import org.springframework.data.domain.Sort; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.data.jpa.repository.Query; 8 | 9 | interface OrderRepository extends JpaRepository { 10 | @Query(""" 11 | select distinct o 12 | from OrderEntity o left join fetch o.orderItem 13 | where o.userId = :userId 14 | """) 15 | List findAllByUserId(Long userId, Sort sort); 16 | 17 | @Query(""" 18 | select distinct o 19 | from OrderEntity o left join fetch o.orderItem 20 | where o.orderNumber = :orderNumber and o.userId = :userId 21 | """) 22 | Optional findByOrderNumberAndUserId(String orderNumber, Long userId); 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/domain/ProductServiceClient.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import com.sivalabs.bookstore.catalog.ProductApi; 4 | import com.sivalabs.bookstore.orders.InvalidOrderException; 5 | import java.math.BigDecimal; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | public class ProductServiceClient { 10 | private final ProductApi productApi; 11 | 12 | public ProductServiceClient(ProductApi productApi) { 13 | this.productApi = productApi; 14 | } 15 | 16 | public void validate(String productCode, BigDecimal price) { 17 | var product = productApi 18 | .getByCode(productCode) 19 | .orElseThrow(() -> new InvalidOrderException("Product not found with code: " + productCode)); 20 | if (product.price().compareTo(price) != 0) { 21 | throw new InvalidOrderException("Product price mismatch"); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/inventory/OrderEventInventoryHandler.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.inventory; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.OrderCreatedEvent; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.modulith.events.ApplicationModuleListener; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | class OrderEventInventoryHandler { 11 | private static final Logger log = LoggerFactory.getLogger(OrderEventInventoryHandler.class); 12 | private final InventoryService inventoryService; 13 | 14 | OrderEventInventoryHandler(InventoryService inventoryService) { 15 | this.inventoryService = inventoryService; 16 | } 17 | 18 | @ApplicationModuleListener 19 | void handle(OrderCreatedEvent event) { 20 | log.info("[Inventory]: Received order created event: {}", event); 21 | inventoryService.decreaseStockLevel(event.productCode(), event.quantity()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /k8s/manifests/zipkin.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: spring-modular-monolith-zipkin-svc 5 | spec: 6 | type: NodePort 7 | selector: 8 | app: spring-modular-monolith-zipkin-pod 9 | ports: 10 | - name: zipkin-port-mapping 11 | port: 9411 12 | targetPort: 9411 13 | protocol: TCP 14 | nodePort: 30092 15 | --- 16 | apiVersion: apps/v1 17 | kind: Deployment 18 | metadata: 19 | name: spring-modular-monolith-zipkin-deployment 20 | spec: 21 | selector: 22 | matchLabels: 23 | app: spring-modular-monolith-zipkin-pod 24 | strategy: 25 | type: Recreate 26 | template: 27 | metadata: 28 | labels: 29 | app: spring-modular-monolith-zipkin-pod 30 | spec: 31 | containers: 32 | - name: zipkin 33 | image: "openzipkin/zipkin:3.5.1" 34 | ports: 35 | - name: zipkin 36 | containerPort: 9411 37 | env: 38 | - name: STORAGE_TYPE 39 | value: mem 40 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/config/WebMvcConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.config; 2 | 3 | import com.sivalabs.bookstore.ApplicationProperties; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.servlet.config.annotation.CorsRegistry; 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 7 | 8 | @Configuration 9 | class WebMvcConfig implements WebMvcConfigurer { 10 | private final ApplicationProperties properties; 11 | 12 | WebMvcConfig(ApplicationProperties properties) { 13 | this.properties = properties; 14 | } 15 | 16 | @Override 17 | public void addCorsMappings(CorsRegistry registry) { 18 | var corsProperties = properties.cors(); 19 | registry.addMapping(corsProperties.pathPattern()) 20 | .allowedOriginPatterns(corsProperties.allowedOrigins()) 21 | .allowedMethods(corsProperties.allowedMethods()) 22 | .allowedHeaders(corsProperties.allowedHeaders()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /k8s/manifests/postgres.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: spring-modular-monolith-postgres-svc 5 | spec: 6 | selector: 7 | app: spring-modular-monolith-postgres-pod 8 | ports: 9 | - port: 5432 10 | targetPort: 5432 11 | --- 12 | apiVersion: apps/v1 13 | kind: Deployment 14 | metadata: 15 | name: spring-modular-monolith-postgres-deployment 16 | spec: 17 | selector: 18 | matchLabels: 19 | app: spring-modular-monolith-postgres-pod 20 | strategy: 21 | type: Recreate 22 | template: 23 | metadata: 24 | labels: 25 | app: spring-modular-monolith-postgres-pod 26 | spec: 27 | containers: 28 | - name: postgres 29 | image: "postgres:18-alpine" 30 | ports: 31 | - name: postgres 32 | containerPort: 5432 33 | env: 34 | - name: POSTGRES_USER 35 | value: postgres 36 | - name: POSTGRES_PASSWORD 37 | value: postgres 38 | - name: POSTGRES_DB 39 | value: postgres 40 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/catalog/web/CatalogExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.web; 2 | 3 | import com.sivalabs.bookstore.catalog.domain.ProductNotFoundException; 4 | import java.time.Instant; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.ProblemDetail; 7 | import org.springframework.web.bind.annotation.ExceptionHandler; 8 | import org.springframework.web.bind.annotation.RestControllerAdvice; 9 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; 10 | 11 | @RestControllerAdvice 12 | class CatalogExceptionHandler extends ResponseEntityExceptionHandler { 13 | 14 | @ExceptionHandler(ProductNotFoundException.class) 15 | ProblemDetail handle(ProductNotFoundException e) { 16 | ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); 17 | problemDetail.setTitle("Product Not Found"); 18 | problemDetail.setProperty("timestamp", Instant.now()); 19 | return problemDetail; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/CreateOrderRequest.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.Customer; 4 | import com.sivalabs.bookstore.orders.domain.models.OrderItem; 5 | import jakarta.validation.Valid; 6 | import jakarta.validation.constraints.NotEmpty; 7 | import org.jspecify.annotations.Nullable; 8 | 9 | public record CreateOrderRequest( 10 | @Nullable UserId userId, 11 | @Valid Customer customer, 12 | @NotEmpty String deliveryAddress, 13 | @Valid OrderItem item) { 14 | 15 | public CreateOrderRequest withUserId(Long userId) { 16 | return new CreateOrderRequest(new UserId(userId), customer, deliveryAddress, item); 17 | } 18 | 19 | public static class UserId { 20 | private Long userId; 21 | 22 | public UserId(Long userId) { 23 | this.userId = userId; 24 | } 25 | 26 | public void setUserId(Long userId) { 27 | this.userId = userId; 28 | } 29 | 30 | public Long getUserId() { 31 | return userId; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/UserService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | import java.util.Optional; 4 | import org.springframework.security.crypto.password.PasswordEncoder; 5 | import org.springframework.stereotype.Service; 6 | import org.springframework.transaction.annotation.Transactional; 7 | 8 | @Service 9 | public class UserService { 10 | private final UserRepository userRepository; 11 | private final PasswordEncoder passwordEncoder; 12 | 13 | UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { 14 | this.userRepository = userRepository; 15 | this.passwordEncoder = passwordEncoder; 16 | } 17 | 18 | @Transactional(readOnly = true) 19 | public Optional findByEmail(String email) { 20 | return userRepository.findByEmailIgnoreCase(email).map(UserMapper::toUser); 21 | } 22 | 23 | @Transactional 24 | public void createUser(CreateUserCmd cmd) { 25 | var user = new UserEntity(); 26 | user.setName(cmd.name()); 27 | user.setEmail(cmd.email()); 28 | user.setPassword(passwordEncoder.encode(cmd.password())); 29 | user.setRole(cmd.role()); 30 | userRepository.save(user); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/UserContextUtils.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users; 2 | 3 | import com.sivalabs.bookstore.users.domain.SecurityUser; 4 | import org.springframework.security.access.AccessDeniedException; 5 | import org.springframework.security.core.Authentication; 6 | import org.springframework.security.core.context.SecurityContextHolder; 7 | import org.springframework.security.oauth2.jwt.Jwt; 8 | 9 | public class UserContextUtils { 10 | public static Long getCurrentUserIdOrThrow() { 11 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 12 | if (authentication == null) { 13 | throw new AccessDeniedException("Access denied"); 14 | } 15 | var principal = authentication.getPrincipal(); 16 | if (principal instanceof SecurityUser securityUser) { 17 | return securityUser.getId(); 18 | } else if (principal instanceof Jwt jwt) { 19 | Long userId = jwt.getClaim("user_id"); 20 | if (userId != null) { 21 | return userId; 22 | } 23 | throw new AccessDeniedException("Access denied"); 24 | } 25 | throw new AccessDeniedException("Access denied"); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /k8s/manifests/rabbitmq.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: spring-modular-monolith-rabbitmq-svc 5 | spec: 6 | type: NodePort 7 | selector: 8 | app: spring-modular-monolith-rabbitmq-pod 9 | ports: 10 | - name: rabbitmq-port-mapping 11 | port: 5672 12 | targetPort: 5672 13 | protocol: TCP 14 | - name: rabbitmq--gui-port-mapping 15 | port: 15672 16 | targetPort: 15672 17 | protocol: TCP 18 | nodePort: 30091 19 | --- 20 | apiVersion: apps/v1 21 | kind: Deployment 22 | metadata: 23 | name: spring-modular-monolith-rabbitmq-deployment 24 | spec: 25 | selector: 26 | matchLabels: 27 | app: spring-modular-monolith-rabbitmq-pod 28 | strategy: 29 | type: Recreate 30 | template: 31 | metadata: 32 | labels: 33 | app: spring-modular-monolith-rabbitmq-pod 34 | spec: 35 | containers: 36 | - name: rabbitmq 37 | image: "rabbitmq:4.2.1-management" 38 | ports: 39 | - name: rabbitmq 40 | containerPort: 5672 41 | - name: rabbitmq-admin 42 | containerPort: 15672 43 | env: 44 | - name: RABBITMQ_DEFAULT_USER 45 | value: guest 46 | - name: RABBITMQ_DEFAULT_PASS 47 | value: guest 48 | 49 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/SecurityUserDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | import org.springframework.security.core.userdetails.UserDetails; 4 | import org.springframework.security.core.userdetails.UserDetailsService; 5 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 6 | import org.springframework.stereotype.Service; 7 | 8 | @Service 9 | class SecurityUserDetailsService implements UserDetailsService { 10 | private final UserRepository userRepository; 11 | 12 | SecurityUserDetailsService(UserRepository userRepository) { 13 | this.userRepository = userRepository; 14 | } 15 | 16 | @Override 17 | public UserDetails loadUserByUsername(String userName) { 18 | return userRepository 19 | .findByEmailIgnoreCase(userName) 20 | .map(this::toSecurityUser) 21 | .orElseThrow(() -> new UsernameNotFoundException("Email " + userName + " not found")); 22 | } 23 | 24 | private SecurityUser toSecurityUser(UserEntity user) { 25 | return new SecurityUser( 26 | user.getId(), 27 | user.getName(), 28 | user.getEmail(), 29 | user.getPassword(), 30 | user.getRole().name()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/catalog/domain/ProductService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | import com.sivalabs.bookstore.common.models.PagedResult; 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 | public class ProductService { 14 | private static final int PRODUCT_PAGE_SIZE = 10; 15 | private final ProductRepository repo; 16 | 17 | ProductService(ProductRepository repo) { 18 | this.repo = repo; 19 | } 20 | 21 | @Transactional(readOnly = true) 22 | public PagedResult getProducts(int pageNo) { 23 | Sort sort = Sort.by("name").ascending(); 24 | int page = pageNo <= 1 ? 0 : pageNo - 1; 25 | Pageable pageable = PageRequest.of(page, PRODUCT_PAGE_SIZE, sort); 26 | Page productsPage = repo.findAll(pageable); 27 | return new PagedResult<>(productsPage); 28 | } 29 | 30 | @Transactional(readOnly = true) 31 | public Optional getByCode(String code) { 32 | return repo.findByCode(code); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/common/models/PagedResult.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.common.models; 2 | 3 | import java.util.List; 4 | import java.util.function.Function; 5 | import org.springframework.data.domain.Page; 6 | 7 | public record PagedResult( 8 | List data, 9 | long totalElements, 10 | int pageNumber, 11 | int totalPages, 12 | boolean isFirst, 13 | boolean isLast, 14 | boolean hasNext, 15 | boolean hasPrevious) { 16 | 17 | public PagedResult(Page page) { 18 | this( 19 | page.getContent(), 20 | page.getTotalElements(), 21 | page.getNumber() + 1, 22 | page.getTotalPages(), 23 | page.isFirst(), 24 | page.isLast(), 25 | page.hasNext(), 26 | page.hasPrevious()); 27 | } 28 | 29 | public static PagedResult of(PagedResult pagedResult, Function mapper) { 30 | return new PagedResult<>( 31 | pagedResult.data.stream().map(mapper).toList(), 32 | pagedResult.totalElements, 33 | pagedResult.pageNumber, 34 | pagedResult.totalPages, 35 | pagedResult.isFirst, 36 | pagedResult.isLast, 37 | pagedResult.hasNext, 38 | pagedResult.hasPrevious); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: 'postgres:18-alpine' 4 | environment: 5 | - 'POSTGRES_DB=postgres' 6 | - 'POSTGRES_PASSWORD=postgres' 7 | - 'POSTGRES_USER=postgres' 8 | ports: 9 | - '5432:5432' 10 | 11 | rabbitmq: 12 | image: 'rabbitmq:4.2.1-management' 13 | environment: 14 | - 'RABBITMQ_DEFAULT_USER=guest' 15 | - 'RABBITMQ_DEFAULT_PASS=guest' 16 | ports: 17 | - '5672:5672' 18 | - '15672:15672' 19 | zipkin: 20 | image: 'openzipkin/zipkin:3.5.1' 21 | environment: 22 | - STORAGE_TYPE=mem 23 | ports: 24 | - '9411:9411' 25 | 26 | spring-modular-monolith: 27 | image: sivaprasadreddy/spring-modular-monolith 28 | container_name: spring-modular-monolith 29 | environment: 30 | SPRING_PROFILES_ACTIVE: docker 31 | SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver 32 | SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/postgres 33 | SPRING_DATASOURCE_USERNAME: postgres 34 | SPRING_DATASOURCE_PASSWORD: postgres 35 | SPRING_RABBITMQ_HOST: rabbitmq 36 | SPRING_RABBITMQ_PORT: 5672 37 | SPRING_RABBITMQ_USERNAME: guest 38 | SPRING_RABBITMQ_PASSWORD: guest 39 | MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: http://zipkin:9411/api/v2/spans 40 | ports: 41 | - "8080:8080" 42 | depends_on: 43 | - postgres 44 | - rabbitmq 45 | - zipkin 46 | restart: unless-stopped 47 | profiles: 48 | - app 49 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/JwtTokenHelper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | import com.sivalabs.bookstore.ApplicationProperties; 4 | import java.time.Instant; 5 | import org.springframework.security.oauth2.jwt.JwtClaimsSet; 6 | import org.springframework.security.oauth2.jwt.JwtEncoder; 7 | import org.springframework.security.oauth2.jwt.JwtEncoderParameters; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | public class JwtTokenHelper { 12 | private final JwtEncoder encoder; 13 | private final ApplicationProperties properties; 14 | 15 | JwtTokenHelper(JwtEncoder encoder, ApplicationProperties properties) { 16 | this.encoder = encoder; 17 | this.properties = properties; 18 | } 19 | 20 | public JwtToken generateToken(UserDto userDto) { 21 | Instant now = Instant.now(); 22 | Instant expiresAt = now.plusSeconds(properties.jwt().expiresInSeconds()); 23 | JwtClaimsSet claims = JwtClaimsSet.builder() 24 | .issuer(properties.jwt().issuer()) 25 | .issuedAt(now) 26 | .expiresAt(expiresAt) 27 | .subject(userDto.email()) 28 | .claim("user_id", userDto.id()) 29 | .claim("roles", userDto.role().name()) 30 | .build(); 31 | var token = this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); 32 | return new JwtToken(token, expiresAt); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/web/OrdersExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web; 2 | 3 | import com.sivalabs.bookstore.orders.InvalidOrderException; 4 | import com.sivalabs.bookstore.orders.OrderNotFoundException; 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 OrdersExceptionHandler extends ResponseEntityExceptionHandler { 14 | 15 | @ExceptionHandler(OrderNotFoundException.class) 16 | ProblemDetail handle(OrderNotFoundException e) { 17 | ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); 18 | problemDetail.setTitle("Order Not Found"); 19 | problemDetail.setProperty("timestamp", Instant.now()); 20 | return problemDetail; 21 | } 22 | 23 | @ExceptionHandler(InvalidOrderException.class) 24 | ProblemDetail handle(InvalidOrderException e) { 25 | ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage()); 26 | problemDetail.setTitle("Invalid Order Creation Request"); 27 | problemDetail.setProperty("timestamp", Instant.now()); 28 | return problemDetail; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/config/CommonSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.config; 2 | 3 | import com.sivalabs.bookstore.users.domain.Role; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.security.access.hierarchicalroles.RoleHierarchy; 7 | import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; 8 | import org.springframework.security.authentication.AuthenticationManager; 9 | import org.springframework.security.authentication.ProviderManager; 10 | import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 11 | import org.springframework.security.core.userdetails.UserDetailsService; 12 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 13 | import org.springframework.security.crypto.password.PasswordEncoder; 14 | 15 | @Configuration 16 | class CommonSecurityConfig { 17 | 18 | @Bean 19 | PasswordEncoder passwordEncoder() { 20 | return new BCryptPasswordEncoder(); 21 | } 22 | 23 | @Bean 24 | public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder pe) { 25 | var authProvider = new DaoAuthenticationProvider(userDetailsService); 26 | authProvider.setPasswordEncoder(pe); 27 | return new ProviderManager(authProvider); 28 | } 29 | 30 | @Bean 31 | RoleHierarchy roleHierarchy() { 32 | return RoleHierarchyImpl.fromHierarchy(Role.getRoleHierarchy()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/OrdersApi.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders; 2 | 3 | import com.sivalabs.bookstore.orders.domain.OrderEntity; 4 | import com.sivalabs.bookstore.orders.domain.OrderService; 5 | import com.sivalabs.bookstore.orders.mappers.OrderMapper; 6 | import java.util.List; 7 | import java.util.Optional; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Component 11 | public class OrdersApi { 12 | private final OrderService orderService; 13 | 14 | public OrdersApi(OrderService orderService) { 15 | this.orderService = orderService; 16 | } 17 | 18 | public CreateOrderResponse createOrder(CreateOrderRequest request) { 19 | OrderEntity orderEntity = OrderMapper.convertToEntity(request); 20 | var order = orderService.createOrder(orderEntity); 21 | return new CreateOrderResponse(order.getOrderNumber()); 22 | } 23 | 24 | public Optional findOrder(String orderNumber, Long userId) { 25 | Optional byOrderNumber = orderService.findOrder(orderNumber, userId); 26 | if (byOrderNumber.isEmpty()) { 27 | return Optional.empty(); 28 | } 29 | OrderEntity orderEntity = byOrderNumber.get(); 30 | var orderDto = OrderMapper.convertToDto(orderEntity); 31 | return Optional.of(orderDto); 32 | } 33 | 34 | public List findOrders(Long userId) { 35 | List orders = orderService.findOrders(userId); 36 | return OrderMapper.convertToOrderViews(orders); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/java/com/sivalabs/bookstore/inventory/InventoryIntegrationTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.inventory; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 5 | 6 | import com.sivalabs.bookstore.TestcontainersConfiguration; 7 | import com.sivalabs.bookstore.orders.domain.models.Customer; 8 | import com.sivalabs.bookstore.orders.domain.models.OrderCreatedEvent; 9 | import java.util.UUID; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.context.annotation.Import; 13 | import org.springframework.modulith.test.ApplicationModuleTest; 14 | import org.springframework.modulith.test.Scenario; 15 | 16 | @ApplicationModuleTest(webEnvironment = RANDOM_PORT) 17 | @Import(TestcontainersConfiguration.class) 18 | class InventoryIntegrationTests { 19 | 20 | @Autowired 21 | private InventoryService inventoryService; 22 | 23 | @Test 24 | void handleOrderCreatedEvent(Scenario scenario) { 25 | var customer = new Customer("Siva", "siva@gmail.com", "9987654"); 26 | String productCode = "P114"; 27 | var event = new OrderCreatedEvent(UUID.randomUUID().toString(), productCode, 2, customer); 28 | var stockLevelChange = 29 | scenario.publish(event).andWaitForStateChange(() -> inventoryService.getStockLevel(productCode) == 598); 30 | stockLevelChange.andVerify(result -> assertThat(result).isTrue()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/SecurityUser.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | import java.io.Serial; 4 | import java.io.Serializable; 5 | import java.util.Collection; 6 | import java.util.Set; 7 | import org.springframework.security.core.GrantedAuthority; 8 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 9 | import org.springframework.security.core.userdetails.UserDetails; 10 | 11 | public class SecurityUser implements UserDetails, Serializable { 12 | @Serial 13 | private static final long serialVersionUID = 1L; 14 | 15 | private final Long id; 16 | private final String name; 17 | private final String email; 18 | private final String password; 19 | private final String role; 20 | 21 | public SecurityUser(Long id, String name, String email, String password, String role) { 22 | this.id = id; 23 | this.name = name; 24 | this.email = email; 25 | this.password = password; 26 | this.role = role; 27 | } 28 | 29 | @Override 30 | public Collection getAuthorities() { 31 | return Set.of(new SimpleGrantedAuthority(role)); 32 | } 33 | 34 | @Override 35 | public String getPassword() { 36 | return password; 37 | } 38 | 39 | @Override 40 | public String getUsername() { 41 | return email; 42 | } 43 | 44 | public Long getId() { 45 | return id; 46 | } 47 | 48 | public String getName() { 49 | return name; 50 | } 51 | 52 | public String getRole() { 53 | return role; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/domain/UserEntity.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.domain; 2 | 3 | import jakarta.persistence.*; 4 | 5 | @Entity 6 | @Table(name = "users", schema = "users") 7 | public class UserEntity { 8 | 9 | @Id 10 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_id_generator") 11 | @SequenceGenerator(name = "user_id_generator", sequenceName = "user_id_seq", schema = "users") 12 | private Long id; 13 | 14 | @Column(nullable = false) 15 | private String name; 16 | 17 | @Column(nullable = false, unique = true) 18 | private String email; 19 | 20 | @Column(nullable = false) 21 | private String password; 22 | 23 | @Column(nullable = false) 24 | @Enumerated(EnumType.STRING) 25 | private Role role; 26 | 27 | public Long getId() { 28 | return id; 29 | } 30 | 31 | public void setId(Long id) { 32 | this.id = id; 33 | } 34 | 35 | public String getName() { 36 | return name; 37 | } 38 | 39 | public void setName(String name) { 40 | this.name = name; 41 | } 42 | 43 | public String getEmail() { 44 | return email; 45 | } 46 | 47 | public void setEmail(String email) { 48 | this.email = email; 49 | } 50 | 51 | public String getPassword() { 52 | return password; 53 | } 54 | 55 | public void setPassword(String password) { 56 | this.password = password; 57 | } 58 | 59 | public Role getRole() { 60 | return role; 61 | } 62 | 63 | public void setRole(Role role) { 64 | this.role = role; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/inventory/InventoryEntity.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.inventory; 2 | 3 | import static jakarta.persistence.GenerationType.SEQUENCE; 4 | 5 | import jakarta.persistence.Column; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.GeneratedValue; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.SequenceGenerator; 10 | import jakarta.persistence.Table; 11 | import jakarta.validation.constraints.NotEmpty; 12 | import org.jspecify.annotations.NullUnmarked; 13 | 14 | @Entity 15 | @Table(name = "inventory", schema = "inventory") 16 | @NullUnmarked 17 | class InventoryEntity { 18 | @Id 19 | @GeneratedValue(strategy = SEQUENCE, generator = "inventory_id_generator") 20 | @SequenceGenerator(name = "inventory_id_generator", sequenceName = "inventory_id_seq", schema = "inventory") 21 | private Long id; 22 | 23 | @Column(name = "product_code", nullable = false, unique = true) 24 | @NotEmpty(message = "Product code is required") private String productCode; 25 | 26 | @Column(nullable = false) 27 | private Long quantity; 28 | 29 | public Long getId() { 30 | return id; 31 | } 32 | 33 | public void setId(Long id) { 34 | this.id = id; 35 | } 36 | 37 | public String getProductCode() { 38 | return productCode; 39 | } 40 | 41 | public void setProductCode(String productCode) { 42 | this.productCode = productCode; 43 | } 44 | 45 | public Long getQuantity() { 46 | return quantity; 47 | } 48 | 49 | public void setQuantity(Long quantity) { 50 | this.quantity = quantity; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/test/java/com/sivalabs/bookstore/TestcontainersConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore; 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.GenericContainer; 7 | import org.testcontainers.junit.jupiter.Container; 8 | import org.testcontainers.junit.jupiter.Testcontainers; 9 | import org.testcontainers.postgresql.PostgreSQLContainer; 10 | import org.testcontainers.rabbitmq.RabbitMQContainer; 11 | import org.testcontainers.utility.DockerImageName; 12 | 13 | @TestConfiguration(proxyBeanMethods = false) 14 | @Testcontainers 15 | public class TestcontainersConfiguration { 16 | 17 | @Container 18 | static PostgreSQLContainer postgres = new PostgreSQLContainer(DockerImageName.parse("postgres:18-alpine")); 19 | 20 | @Container 21 | static RabbitMQContainer rabbitmq = new RabbitMQContainer(DockerImageName.parse("rabbitmq:4.2.1-alpine")); 22 | 23 | @Container 24 | static GenericContainer zipkin = 25 | new GenericContainer<>(DockerImageName.parse("openzipkin/zipkin:3.5.1")).withExposedPorts(9411); 26 | 27 | @Bean 28 | @ServiceConnection 29 | PostgreSQLContainer postgresContainer() { 30 | return postgres; 31 | } 32 | 33 | @Bean 34 | @ServiceConnection 35 | RabbitMQContainer rabbitmq() { 36 | return rabbitmq; 37 | } 38 | 39 | @Bean 40 | @ServiceConnection(name = "openzipkin/zipkin") 41 | GenericContainer zipkinContainer() { 42 | return zipkin; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/config/OpenApiConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.config; 2 | 3 | import com.sivalabs.bookstore.ApplicationProperties; 4 | import io.swagger.v3.oas.models.Components; 5 | import io.swagger.v3.oas.models.OpenAPI; 6 | import io.swagger.v3.oas.models.info.Contact; 7 | import io.swagger.v3.oas.models.info.Info; 8 | import io.swagger.v3.oas.models.security.SecurityRequirement; 9 | import io.swagger.v3.oas.models.security.SecurityScheme; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Configuration; 12 | 13 | @Configuration 14 | class OpenApiConfig { 15 | 16 | @Bean 17 | OpenAPI openApi(ApplicationProperties properties) { 18 | var openApiProps = properties.openApi(); 19 | Contact contact = new Contact() 20 | .name(openApiProps.contact().name()) 21 | .email(openApiProps.contact().email()); 22 | Info info = new Info() 23 | .title(openApiProps.title()) 24 | .description(openApiProps.description()) 25 | .version(openApiProps.version()) 26 | .contact(contact); 27 | return new OpenAPI() 28 | .info(info) 29 | .addSecurityItem(new SecurityRequirement().addList("Authorization")) 30 | .components(new Components().addSecuritySchemes("Bearer", createJwtTokenScheme())); 31 | } 32 | 33 | private SecurityScheme createJwtTokenScheme() { 34 | return new SecurityScheme() 35 | .name("Authorization") 36 | .type(SecurityScheme.Type.HTTP) 37 | .bearerFormat("JWT") 38 | .scheme("Bearer"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/ApplicationProperties.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore; 2 | 3 | import java.security.interfaces.RSAPrivateKey; 4 | import java.security.interfaces.RSAPublicKey; 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | import org.springframework.boot.context.properties.bind.DefaultValue; 7 | import org.springframework.validation.annotation.Validated; 8 | 9 | @ConfigurationProperties(prefix = "app") 10 | @Validated 11 | public record ApplicationProperties( 12 | String supportEmail, 13 | String newsletterJobCron, 14 | @DefaultValue("10") int postsPerPage, 15 | JwtProperties jwt, 16 | CorsProperties cors, 17 | OpenAPIProperties openApi) { 18 | public record JwtProperties( 19 | @DefaultValue("SivaLabs") String issuer, 20 | @DefaultValue("604800") Long expiresInSeconds, 21 | RSAPublicKey publicKey, 22 | RSAPrivateKey privateKey) {} 23 | 24 | public record CorsProperties( 25 | @DefaultValue("/api/**") String pathPattern, 26 | @DefaultValue("*") String allowedOrigins, 27 | @DefaultValue("*") String allowedMethods, 28 | @DefaultValue("*") String allowedHeaders) {} 29 | 30 | public record OpenAPIProperties( 31 | @DefaultValue("BookStore API") String title, 32 | 33 | @DefaultValue("BookStore API Swagger Documentation") 34 | String description, 35 | 36 | @DefaultValue("v1.0.0") String version, 37 | Contact contact) { 38 | 39 | public record Contact( 40 | @DefaultValue("SivaLabs") String name, 41 | @DefaultValue("support@sivalabs.in") String email) {} 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/inventory/InventoryService.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.inventory; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.stereotype.Service; 6 | import org.springframework.transaction.annotation.Transactional; 7 | 8 | @Service 9 | class InventoryService { 10 | private static final Logger log = LoggerFactory.getLogger(InventoryService.class); 11 | private final InventoryRepository inventoryRepository; 12 | 13 | InventoryService(InventoryRepository inventoryRepository) { 14 | this.inventoryRepository = inventoryRepository; 15 | } 16 | 17 | @Transactional 18 | public void decreaseStockLevel(String productCode, int quantity) { 19 | log.info("Decrease stock level for product code {} and quantity {}", productCode, quantity); 20 | var inventory = inventoryRepository.findByProductCode(productCode).orElse(null); 21 | if (inventory != null) { 22 | long newQuantity = inventory.getQuantity() - quantity; 23 | inventory.setQuantity(newQuantity); 24 | inventoryRepository.save(inventory); 25 | log.info("Updated stock level for product code {} to : {}", productCode, newQuantity); 26 | } else { 27 | log.warn("Invalid product code {}", productCode); 28 | } 29 | } 30 | 31 | @Transactional(readOnly = true) 32 | public Long getStockLevel(String productCode) { 33 | Long stock = inventoryRepository 34 | .findByProductCode(productCode) 35 | .map(InventoryEntity::getQuantity) 36 | .orElse(0L); 37 | log.info("Stock level for product code {} is : {}", productCode, stock); 38 | return stock; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/catalog/web/ProductWebController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.web; 2 | 3 | import com.sivalabs.bookstore.catalog.domain.ProductService; 4 | import com.sivalabs.bookstore.catalog.mappers.ProductMapper; 5 | import com.sivalabs.bookstore.common.models.PagedResult; 6 | import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRequest; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.stereotype.Controller; 10 | import org.springframework.ui.Model; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.RequestParam; 13 | 14 | @Controller 15 | class ProductWebController { 16 | private static final Logger log = LoggerFactory.getLogger(ProductWebController.class); 17 | 18 | private final ProductService productService; 19 | private final ProductMapper productMapper; 20 | 21 | ProductWebController(ProductService productService, ProductMapper productMapper) { 22 | this.productService = productService; 23 | this.productMapper = productMapper; 24 | } 25 | 26 | @GetMapping 27 | String index() { 28 | return "redirect:/products"; 29 | } 30 | 31 | @GetMapping("/products") 32 | String showProducts(@RequestParam(defaultValue = "1") int page, Model model, HtmxRequest hxRequest) { 33 | log.info("Fetching products for page: {}", page); 34 | var pagedResult = productService.getProducts(page); 35 | var productsPage = PagedResult.of(pagedResult, productMapper::mapToDto); 36 | model.addAttribute("productsPage", productsPage); 37 | if (hxRequest.isHtmxRequest()) { 38 | return "partials/products"; 39 | } 40 | return "products"; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/resources/templates/partials/pagination.html: -------------------------------------------------------------------------------- 1 |
2 | 26 |
-------------------------------------------------------------------------------- /src/main/resources/certs/keypair.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDATu3F2T4P+sXz 3 | zOUDYRwS7V+45x5p56Mix94QfklhRsVaNoLsT5cJqK8OOoQ+lA/3n5Nf5wOki5n1 4 | 5+Dq4bMft5bOwa8mJQ/xSSLBXTfW5LP2oMblah2geqGu9nh7Fl4oVwSLmFIEMJup 5 | NJprtyAKsQqwXihCo2Fs5W80bQ+TrO7bI1VRY3GEyoAoS4BMP6JmTVLqgMp5M25K 6 | TptH1bV9HOAXV6YAtmvB7NULgWRfS9oiTztYJx2Fq0gjW6zSqqQFe8s6XIXYWzew 7 | ucttH77j2kPW3tDZCe6YGtZ0nY+/9B7OXctBxtI7nJ5/Oxem/DHTsLkmDraLGc3i 8 | UlnATN9NAgMBAAECggEAAkTsKVmBkflePJzpAZEzyPAOy8UZEXGE9fQbJWFYHFMU 9 | IRcOPxub/KaGP59mesE52azYYFlULFeOtDLNmffv1SqlbOsm1fG6A8Qz1Mmfebmu 10 | e0qKY5/3mYfY6zzypFaEkJVLncS+Xa5KfUSm+H5rXQpEcLBWaUg5mL9cVedeN6/d 11 | d4gAB/u6TsXGeJIrVGwfyOfSr8SKj2fCmcZFSPDaarGGHoVVGh/zR0GofD8pv5I1 12 | UbQwipI3Ty7DrciDrNso9OnFtEvovHanTit3TQlxAT4/X1rWWZ1AiZNbAwSzE7TG 13 | DLniYss1gtyB9Xksi4kZSO2hrmB6HxLiRw1b22lYAQKBgQDtGy3N5AhUT2uKBpqQ 14 | iDV/m3lXwLTVTPCULAXzGXSc1nT81aoLcqsR8GZK2UW7ekVk/qmfQPDE2yyud8mP 15 | qep1wY5HoqI4jQ3N39FcTX4qz19M0Q5yRDh1aim2OKlY/tVtXSYJtLW21o/UCCVH 16 | h8NLrrwAQ2qfknnFul0lECWmbQKBgQDPoeaqvjVHos44ANEwrGLI2fb8LndTNFIT 17 | 31HLwKabAgp813y8R4REAxdl0Fz8Sv+Ky8EdYjd9R2FnnPD4Ei8/5XO1qp1FOdlo 18 | OCfyfPU0vHOy9hNWC65VX9przpZjpAnO9adHEILhxXwwNZtS+gsqVBnRKIwXHrE9 19 | XQsxyOoQYQKBgQCCESnESzYigdq9QbgiVwX59WDQOZ85b1Z+AdRVsf4dVyuf0tnQ 20 | I9wiIB0NLDkrifxtVaHZAbfSVWUiZAXG8G/0nvQc6eNRYFdVO1VO7BetBksCCaCC 21 | IFhUWKN/GYAUmN6der62DlKsdPE7YCiLH7eLWdQ51MG1vZVdWUllXoE41QKBgQC5 22 | ZSDoGIrOeiqUivY+9c4G9ci5iGv3mWIoaGFLA6xAAGSI8IhqPZl2eSQtPw2oIPdo 23 | YWL/77EIZfItaE8p0mLqNOFKtxtSssLTckEJHlZ8TkEo7Nx7GlcB2GLZnE9gjRpM 24 | 97/zjmSvX3zyNwuH3ciWdR3QSto70qYD2s6iF3oYQQKBgQCFZXu8+bAUpTtfKkib 25 | bS7yY95FnsmydJ56m0TZgEle07folwyu8hpq1E7jr7DKLgzsAlgn0nrI3E5HDQ5H 26 | YbMVR4tctg49gU9tO73lkVMvtCDkLKSXY/FLwjsETslKzec7yUyMfRtuW42Fzk3a 27 | WfVhBrm55e550oBb/5DhWHIlGQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /src/main/resources/certs/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDATu3F2T4P+sXz 3 | zOUDYRwS7V+45x5p56Mix94QfklhRsVaNoLsT5cJqK8OOoQ+lA/3n5Nf5wOki5n1 4 | 5+Dq4bMft5bOwa8mJQ/xSSLBXTfW5LP2oMblah2geqGu9nh7Fl4oVwSLmFIEMJup 5 | NJprtyAKsQqwXihCo2Fs5W80bQ+TrO7bI1VRY3GEyoAoS4BMP6JmTVLqgMp5M25K 6 | TptH1bV9HOAXV6YAtmvB7NULgWRfS9oiTztYJx2Fq0gjW6zSqqQFe8s6XIXYWzew 7 | ucttH77j2kPW3tDZCe6YGtZ0nY+/9B7OXctBxtI7nJ5/Oxem/DHTsLkmDraLGc3i 8 | UlnATN9NAgMBAAECggEAAkTsKVmBkflePJzpAZEzyPAOy8UZEXGE9fQbJWFYHFMU 9 | IRcOPxub/KaGP59mesE52azYYFlULFeOtDLNmffv1SqlbOsm1fG6A8Qz1Mmfebmu 10 | e0qKY5/3mYfY6zzypFaEkJVLncS+Xa5KfUSm+H5rXQpEcLBWaUg5mL9cVedeN6/d 11 | d4gAB/u6TsXGeJIrVGwfyOfSr8SKj2fCmcZFSPDaarGGHoVVGh/zR0GofD8pv5I1 12 | UbQwipI3Ty7DrciDrNso9OnFtEvovHanTit3TQlxAT4/X1rWWZ1AiZNbAwSzE7TG 13 | DLniYss1gtyB9Xksi4kZSO2hrmB6HxLiRw1b22lYAQKBgQDtGy3N5AhUT2uKBpqQ 14 | iDV/m3lXwLTVTPCULAXzGXSc1nT81aoLcqsR8GZK2UW7ekVk/qmfQPDE2yyud8mP 15 | qep1wY5HoqI4jQ3N39FcTX4qz19M0Q5yRDh1aim2OKlY/tVtXSYJtLW21o/UCCVH 16 | h8NLrrwAQ2qfknnFul0lECWmbQKBgQDPoeaqvjVHos44ANEwrGLI2fb8LndTNFIT 17 | 31HLwKabAgp813y8R4REAxdl0Fz8Sv+Ky8EdYjd9R2FnnPD4Ei8/5XO1qp1FOdlo 18 | OCfyfPU0vHOy9hNWC65VX9przpZjpAnO9adHEILhxXwwNZtS+gsqVBnRKIwXHrE9 19 | XQsxyOoQYQKBgQCCESnESzYigdq9QbgiVwX59WDQOZ85b1Z+AdRVsf4dVyuf0tnQ 20 | I9wiIB0NLDkrifxtVaHZAbfSVWUiZAXG8G/0nvQc6eNRYFdVO1VO7BetBksCCaCC 21 | IFhUWKN/GYAUmN6der62DlKsdPE7YCiLH7eLWdQ51MG1vZVdWUllXoE41QKBgQC5 22 | ZSDoGIrOeiqUivY+9c4G9ci5iGv3mWIoaGFLA6xAAGSI8IhqPZl2eSQtPw2oIPdo 23 | YWL/77EIZfItaE8p0mLqNOFKtxtSssLTckEJHlZ8TkEo7Nx7GlcB2GLZnE9gjRpM 24 | 97/zjmSvX3zyNwuH3ciWdR3QSto70qYD2s6iF3oYQQKBgQCFZXu8+bAUpTtfKkib 25 | bS7yY95FnsmydJ56m0TZgEle07folwyu8hpq1E7jr7DKLgzsAlgn0nrI3E5HDQ5H 26 | YbMVR4tctg49gU9tO73lkVMvtCDkLKSXY/FLwjsETslKzec7yUyMfRtuW42Fzk3a 27 | WfVhBrm55e550oBb/5DhWHIlGQ== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /src/main/resources/templates/partials/products.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 | Product Image 13 | 14 |
15 |
product.name
19 |

product.price

23 |
24 | 32 |
33 |
34 |
35 |
36 |
-------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/config/RabbitMQConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.config; 2 | 3 | import org.springframework.amqp.core.Binding; 4 | import org.springframework.amqp.core.BindingBuilder; 5 | import org.springframework.amqp.core.Queue; 6 | import org.springframework.amqp.core.TopicExchange; 7 | import org.springframework.amqp.rabbit.connection.ConnectionFactory; 8 | import org.springframework.amqp.rabbit.core.RabbitTemplate; 9 | import org.springframework.amqp.support.converter.JacksonJsonMessageConverter; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Configuration; 12 | import tools.jackson.databind.json.JsonMapper; 13 | 14 | @Configuration 15 | class RabbitMQConfig { 16 | 17 | public static final String EXCHANGE_NAME = "BookStoreExchange"; 18 | 19 | public static final String ROUTING_KEY = "orders.new"; 20 | 21 | public static final String QUEUE_NAME = "new-orders"; 22 | 23 | @Bean 24 | TopicExchange exchange() { 25 | return new TopicExchange(EXCHANGE_NAME); 26 | } 27 | 28 | @Bean 29 | Queue newOrdersQueue() { 30 | return new Queue(QUEUE_NAME, false); 31 | } 32 | 33 | @Bean 34 | Binding newOrdersQueueBinding(Queue newOrdersQueue, TopicExchange exchange) { 35 | return BindingBuilder.bind(newOrdersQueue).to(exchange).with(ROUTING_KEY); 36 | } 37 | 38 | @Bean 39 | RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, JsonMapper jsonMapper) { 40 | final var rabbitTemplate = new RabbitTemplate(connectionFactory); 41 | rabbitTemplate.setMessageConverter(producerJackson2MessageConverter(jsonMapper)); 42 | return rabbitTemplate; 43 | } 44 | 45 | @Bean 46 | JacksonJsonMessageConverter producerJackson2MessageConverter(JsonMapper jsonMapper) { 47 | return new JacksonJsonMessageConverter(jsonMapper); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /k8s/check-ready.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 5 | cd "$SCRIPT_DIR/.." || exit 1 6 | 7 | fail() { echo "ERROR: $*" >&2; exit 1; } 8 | info() { echo "INFO: $*"; } 9 | 10 | command -v kubectl >/dev/null 2>&1 || fail "kubectl not found in PATH" 11 | 12 | info "kubectl version:"; kubectl version --client --short || true 13 | 14 | # cluster name from kind-config.yml if present 15 | if [[ -f k8s/kind/kind-config.yml ]]; then 16 | CLUSTER_NAME=$(awk '/^\s*name:\s*/{print $2; exit}' k8s/kind/kind-config.yml || true) 17 | else 18 | CLUSTER_NAME="kind" 19 | fi 20 | 21 | info "Cluster name: ${CLUSTER_NAME:-kind}" 22 | 23 | info "Nodes:" 24 | kubectl get nodes -o wide || true 25 | 26 | info "Pods (all namespaces):" 27 | kubectl get pods -A -o wide || true 28 | 29 | info "Pods not in Running phase (if any):" 30 | kubectl get pods -A --field-selector=status.phase!=Running || echo "(none)" 31 | 32 | info "Pods with restart count > 0 (if any):" 33 | kubectl get pods -A --no-headers | awk '$4+0>0 {print $0}' || echo "(none)" 34 | 35 | # Collect list of suspect pods (not Running or restart >0) 36 | mapfile -t suspect < <(kubectl get pods -A --no-headers | awk '$4+0>0 || $4 ~ /0\// || $3!~/Running/ {print $1"/"$2}' | sort -u || true) 37 | 38 | if [[ ${#suspect[@]} -eq 0 ]]; then 39 | info "No suspect pods detected." 40 | exit 0 41 | fi 42 | 43 | info "Inspecting ${#suspect[@]} suspect pods for diagnostics..." 44 | for p in "${suspect[@]}"; do 45 | ns=${p%%/*} 46 | name=${p#*/} 47 | echo 48 | echo "--- Pod: ${ns}/${name} ---" 49 | kubectl describe pod "$name" -n "$ns" || true 50 | echo 51 | echo "--- Logs (current) for ${name} ---" 52 | kubectl logs "$name" -n "$ns" --all-containers || true 53 | echo 54 | echo "--- Logs (previous) for ${name} (if any) ---" 55 | kubectl logs "$name" -n "$ns" --all-containers --previous || echo "(no previous logs)" 56 | done 57 | 58 | exit 0 59 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/mappers/OrderMapper.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.mappers; 2 | 3 | import com.sivalabs.bookstore.orders.CreateOrderRequest; 4 | import com.sivalabs.bookstore.orders.OrderDto; 5 | import com.sivalabs.bookstore.orders.OrderView; 6 | import com.sivalabs.bookstore.orders.domain.OrderEntity; 7 | import com.sivalabs.bookstore.orders.domain.models.OrderStatus; 8 | import java.util.ArrayList; 9 | import java.util.List; 10 | import java.util.UUID; 11 | 12 | public final class OrderMapper { 13 | private OrderMapper() {} 14 | 15 | public static OrderEntity convertToEntity(CreateOrderRequest request) { 16 | OrderEntity entity = new OrderEntity(); 17 | entity.setOrderNumber(UUID.randomUUID().toString()); 18 | if (request.userId() != null) { 19 | entity.setUserId(request.userId().getUserId()); 20 | } 21 | entity.setStatus(OrderStatus.NEW); 22 | entity.setCustomer(request.customer()); 23 | entity.setDeliveryAddress(request.deliveryAddress()); 24 | entity.setOrderItem(request.item()); 25 | return entity; 26 | } 27 | 28 | public static OrderDto convertToDto(OrderEntity order) { 29 | return new OrderDto( 30 | order.getOrderNumber(), 31 | order.getUserId(), 32 | order.getOrderItem(), 33 | order.getCustomer(), 34 | order.getDeliveryAddress(), 35 | order.getStatus(), 36 | order.getCreatedAt()); 37 | } 38 | 39 | public static List convertToOrderViews(List orders) { 40 | List orderViews = new ArrayList<>(); 41 | for (OrderEntity order : orders) { 42 | var orderView = new OrderView(order.getOrderNumber(), order.getStatus(), order.getCustomer()); 43 | orderViews.add(orderView); 44 | } 45 | return orderViews; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/catalog/web/ProductRestController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.web; 2 | 3 | import com.sivalabs.bookstore.catalog.ProductDto; 4 | import com.sivalabs.bookstore.catalog.domain.ProductNotFoundException; 5 | import com.sivalabs.bookstore.catalog.domain.ProductService; 6 | import com.sivalabs.bookstore.catalog.mappers.ProductMapper; 7 | import com.sivalabs.bookstore.common.models.PagedResult; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 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 ProductRestController { 19 | private static final Logger log = LoggerFactory.getLogger(ProductRestController.class); 20 | 21 | private final ProductService productService; 22 | private final ProductMapper productMapper; 23 | 24 | ProductRestController(ProductService productService, ProductMapper productMapper) { 25 | this.productService = productService; 26 | this.productMapper = productMapper; 27 | } 28 | 29 | @GetMapping 30 | PagedResult getProducts(@RequestParam(defaultValue = "1") int page) { 31 | log.info("Fetching products for page: {}", page); 32 | var pagedResult = productService.getProducts(page); 33 | return PagedResult.of(pagedResult, productMapper::mapToDto); 34 | } 35 | 36 | @GetMapping("/{code}") 37 | ProductDto getProductByCode(@PathVariable String code) { 38 | log.info("Fetching product by code: {}", code); 39 | return productService 40 | .getByCode(code) 41 | .map(productMapper::mapToDto) 42 | .orElseThrow(() -> ProductNotFoundException.forCode(code)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/resources/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | Login 8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | 18 | 20 |
21 |
22 | 23 | 25 |
26 | 27 |
28 | 29 |
30 |
32 |
Invalid Email and Password
33 |
34 |
36 |
You have been logged out
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /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.OrderCreatedEvent; 4 | import java.util.List; 5 | import java.util.Optional; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.context.ApplicationEventPublisher; 9 | import org.springframework.data.domain.Sort; 10 | import org.springframework.stereotype.Service; 11 | import org.springframework.transaction.annotation.Transactional; 12 | 13 | @Service 14 | public class OrderService { 15 | private static final Logger log = LoggerFactory.getLogger(OrderService.class); 16 | 17 | private final OrderRepository orderRepository; 18 | private final ApplicationEventPublisher eventPublisher; 19 | 20 | OrderService(OrderRepository orderRepository, ApplicationEventPublisher publisher) { 21 | this.orderRepository = orderRepository; 22 | this.eventPublisher = publisher; 23 | } 24 | 25 | @Transactional 26 | public OrderEntity createOrder(OrderEntity orderEntity) { 27 | OrderEntity savedOrder = orderRepository.save(orderEntity); 28 | log.info("Created Order with orderNumber={}", savedOrder.getOrderNumber()); 29 | OrderCreatedEvent event = new OrderCreatedEvent( 30 | savedOrder.getOrderNumber(), 31 | savedOrder.getOrderItem().code(), 32 | savedOrder.getOrderItem().quantity(), 33 | savedOrder.getCustomer()); 34 | eventPublisher.publishEvent(event); 35 | return savedOrder; 36 | } 37 | 38 | @Transactional(readOnly = true) 39 | public Optional findOrder(String orderNumber, Long userId) { 40 | return orderRepository.findByOrderNumberAndUserId(orderNumber, userId); 41 | } 42 | 43 | @Transactional(readOnly = true) 44 | public List findOrders(Long userId) { 45 | Sort sort = Sort.by("id").descending(); 46 | return orderRepository.findAllByUserId(userId, sort); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/resources/templates/partials/cart.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Your cart is empty. Continue shopping

5 |
6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 41 | 42 | 43 |
Product NamePriceQuantitySub Total
[[${cart.item.name}]][[${cart.item.price}]] 21 |
22 | 23 | 30 |
31 |
[[${cart.item.quantity * cart.item.price}]]
39 | Total Amount: [[${cart.totalAmount}]] 40 |
44 |
45 |
46 |
47 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/web/Cart.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web; 2 | 3 | import java.math.BigDecimal; 4 | 5 | public class Cart { 6 | private LineItem item; 7 | 8 | public LineItem getItem() { 9 | return item; 10 | } 11 | 12 | public void setItem(LineItem item) { 13 | this.item = item; 14 | } 15 | 16 | public BigDecimal getTotalAmount() { 17 | if (item == null) { 18 | return BigDecimal.ZERO; 19 | } 20 | return item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())); 21 | } 22 | 23 | public void removeItem() { 24 | this.item = null; 25 | } 26 | 27 | public void updateItemQuantity(int quantity) { 28 | if (quantity <= 0) { 29 | removeItem(); 30 | return; 31 | } 32 | item.setQuantity(quantity); 33 | } 34 | 35 | public static class LineItem { 36 | private String code; 37 | private String name; 38 | private BigDecimal price; 39 | private int quantity; 40 | 41 | public LineItem() {} 42 | 43 | public LineItem(String code, String name, BigDecimal price, int quantity) { 44 | this.code = code; 45 | this.name = name; 46 | this.price = price; 47 | this.quantity = quantity; 48 | } 49 | 50 | public String getCode() { 51 | return code; 52 | } 53 | 54 | public void setCode(String code) { 55 | this.code = code; 56 | } 57 | 58 | public String getName() { 59 | return name; 60 | } 61 | 62 | public void setName(String name) { 63 | this.name = name; 64 | } 65 | 66 | public BigDecimal getPrice() { 67 | return price; 68 | } 69 | 70 | public void setPrice(BigDecimal price) { 71 | this.price = price; 72 | } 73 | 74 | public int getQuantity() { 75 | return quantity; 76 | } 77 | 78 | public void setQuantity(int quantity) { 79 | this.quantity = quantity; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=spring-modular-monolith 2 | spring.threads.virtual.enabled=true 3 | logging.pattern.correlation=[${spring.application.name:},%X{traceId:-},%X{spanId:-}] 4 | logging.include-application-name=false 5 | spring.docker.compose.lifecycle-management=start_only 6 | 7 | #### App Config #### 8 | app.jwt.issuer=SivaLabs 9 | app.jwt.expires-in-seconds=604800 10 | app.jwt.private-key=classpath:certs/private.pem 11 | app.jwt.public-key=classpath:certs/public.pem 12 | 13 | app.cors.path-pattern=/api/** 14 | app.cors.allowed-origins=* 15 | app.cors.allowed-methods=* 16 | app.cors.allowed-headers=* 17 | 18 | app.openapi.title=BookStore API 19 | app.openapi.description=BookStore API Swagger Documentation 20 | app.openapi.version=v1.0.0 21 | app.openapi.contact.name=SivaLabs 22 | app.openapi.contact.email=support@sivalabs.in 23 | 24 | #### Database Config #### 25 | spring.datasource.url=jdbc:postgresql://localhost:5432/postgres 26 | spring.datasource.username=postgres 27 | spring.datasource.password=postgres 28 | spring.jpa.open-in-view=false 29 | spring.jpa.show-sql=false 30 | jdbc.datasource-proxy.query.enable-logging=false 31 | jdbc.datasource-proxy.query.logger-name=bookstore.query-logger 32 | jdbc.datasource-proxy.query.log-level=DEBUG 33 | jdbc.datasource-proxy.multiline=false 34 | logging.level.bookstore.query-logger=DEBUG 35 | 36 | #### RabbitMQ Config #### 37 | spring.rabbitmq.host=localhost 38 | spring.rabbitmq.port=5672 39 | spring.rabbitmq.username=guest 40 | spring.rabbitmq.password=guest 41 | 42 | #### Events Config ###### 43 | spring.modulith.events.jdbc.schema=events 44 | spring.modulith.events.jdbc.schema-initialization.enabled=true 45 | spring.modulith.events.republish-outstanding-events-on-restart=true 46 | #spring.modulith.events.completion-mode=delete 47 | spring.modulith.runtime.flyway-enabled=true 48 | 49 | #### Actuator Config ###### 50 | management.endpoints.web.exposure.include=* 51 | management.endpoint.health.probes.enabled=true 52 | management.tracing.export.enabled=true 53 | management.tracing.sampling.probability=1.0 54 | management.tracing.export.zipkin.endpoint=http://localhost:9411/api/v2/spans 55 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/config/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.core.annotation.Order; 6 | import org.springframework.http.HttpMethod; 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 8 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 9 | import org.springframework.security.web.SecurityFilterChain; 10 | import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; 11 | 12 | @Configuration 13 | @EnableWebSecurity 14 | public class WebSecurityConfig { 15 | @Bean 16 | @Order(2) 17 | SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception { 18 | String[] publicPaths = { 19 | "/", 20 | "/favicon.ico", 21 | "/actuator/**", 22 | "/error", 23 | "/swagger-ui/**", 24 | "/v3/api-docs/**", 25 | "/webjars/**", 26 | "/assets/**", 27 | "/css/**", 28 | "/js/**", 29 | "/images/**", 30 | "/login", 31 | "/registration", 32 | "/registration-success" 33 | }; 34 | http.securityMatcher("/**"); 35 | 36 | http.authorizeHttpRequests(r -> r.requestMatchers(publicPaths) 37 | .permitAll() 38 | .requestMatchers("/admin/**") 39 | .hasRole("ADMIN") 40 | .requestMatchers(HttpMethod.GET, "/products") 41 | .permitAll() 42 | .requestMatchers("/buy", "/cart", "/update-cart") 43 | .permitAll() 44 | .anyRequest() 45 | .authenticated()); 46 | 47 | http.formLogin(formLogin -> formLogin.loginPage("/login").permitAll().defaultSuccessUrl("/", true)); 48 | 49 | http.logout(logout -> logout.logoutRequestMatcher( 50 | PathPatternRequestMatcher.withDefaults().matcher("/logout")) 51 | .logoutSuccessUrl("/") 52 | .permitAll()); 53 | 54 | return http.build(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /k8s/manifests/app.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: spring-modular-monolith-svc 5 | spec: 6 | type: NodePort 7 | selector: 8 | app: spring-modular-monolith-pod 9 | ports: 10 | - name: app-port-mapping 11 | protocol: TCP 12 | port: 8080 13 | targetPort: 8080 14 | nodePort: 30090 15 | --- 16 | apiVersion: apps/v1 17 | kind: Deployment 18 | metadata: 19 | name: spring-modular-monolith-deployment 20 | spec: 21 | replicas: 1 22 | selector: 23 | matchLabels: 24 | app: spring-modular-monolith-pod 25 | template: 26 | metadata: 27 | labels: 28 | app: spring-modular-monolith-pod 29 | spec: 30 | containers: 31 | - name: spring-modular-monolith 32 | image: sivaprasadreddy/spring-modular-monolith 33 | imagePullPolicy: IfNotPresent 34 | ports: 35 | - containerPort: 8080 36 | env: 37 | - name: SPRING_PROFILES_ACTIVE 38 | value: k8s 39 | - name: SPRING_DATASOURCE_URL 40 | value: jdbc:postgresql://spring-modular-monolith-postgres-svc:5432/postgres 41 | - name: SPRING_DATASOURCE_USERNAME 42 | value: postgres 43 | - name: SPRING_DATASOURCE_PASSWORD 44 | value: postgres 45 | - name: SPRING_RABBITMQ_HOST 46 | value: spring-modular-monolith-rabbitmq-svc 47 | - name: SPRING_RABBITMQ_PORT 48 | value: '5672' 49 | - name: SPRING_RABBITMQ_USERNAME 50 | value: guest 51 | - name: SPRING_RABBITMQ_PASSWORD 52 | value: guest 53 | - name: MANAGEMENT_ZIPKIN_TRACING_ENDPOINT 54 | value: http://spring-modular-monolith-zipkin-svc:9411/api/v2/spans 55 | 56 | livenessProbe: 57 | httpGet: 58 | path: /actuator/health/liveness 59 | port: 8080 60 | initialDelaySeconds: 60 61 | timeoutSeconds: 240 62 | readinessProbe: 63 | httpGet: 64 | path: /actuator/health/readiness 65 | port: 8080 66 | initialDelaySeconds: 60 67 | timeoutSeconds: 240 68 | lifecycle: 69 | preStop: 70 | exec: 71 | command: [ "sh", "-c", "sleep 10" ] 72 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/web/UserController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.web; 2 | 3 | import com.sivalabs.bookstore.users.domain.CreateUserCmd; 4 | import com.sivalabs.bookstore.users.domain.Role; 5 | import com.sivalabs.bookstore.users.domain.UserService; 6 | import jakarta.validation.Valid; 7 | import jakarta.validation.constraints.Email; 8 | import jakarta.validation.constraints.NotBlank; 9 | import org.springframework.stereotype.Controller; 10 | import org.springframework.ui.Model; 11 | import org.springframework.validation.BindingResult; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | import org.springframework.web.bind.annotation.ModelAttribute; 14 | import org.springframework.web.bind.annotation.PostMapping; 15 | 16 | @Controller 17 | class UserController { 18 | private final UserService userService; 19 | 20 | UserController(UserService userService) { 21 | this.userService = userService; 22 | } 23 | 24 | @GetMapping("/login") 25 | String loginForm() { 26 | return "login"; 27 | } 28 | 29 | @GetMapping("/registration") 30 | String registrationForm(Model model) { 31 | model.addAttribute("user", new UserRegistrationForm("", "", "")); 32 | return "registration"; 33 | } 34 | 35 | @PostMapping("/registration") 36 | String registerUser( 37 | @ModelAttribute("user") @Valid UserRegistrationForm userRegistrationForm, BindingResult bindingResult) { 38 | if (bindingResult.hasErrors()) { 39 | return "registration"; 40 | } 41 | var cmd = new CreateUserCmd( 42 | userRegistrationForm.name(), 43 | userRegistrationForm.email(), 44 | userRegistrationForm.password(), 45 | Role.ROLE_USER); 46 | userService.createUser(cmd); 47 | return "redirect:/registration-success"; 48 | } 49 | 50 | @GetMapping("/registration-success") 51 | String registrationSuccess() { 52 | return "registration-success"; 53 | } 54 | 55 | public record UserRegistrationForm( 56 | @NotBlank(message = "Name is required") String name, 57 | 58 | @NotBlank(message = "Email is required") @Email(message = "Invalid email address") String email, 59 | 60 | @NotBlank(message = "Password is required") String password) {} 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/config/JwtSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.config; 2 | 3 | import com.nimbusds.jose.jwk.JWK; 4 | import com.nimbusds.jose.jwk.JWKSet; 5 | import com.nimbusds.jose.jwk.RSAKey; 6 | import com.nimbusds.jose.jwk.source.ImmutableJWKSet; 7 | import com.nimbusds.jose.jwk.source.JWKSource; 8 | import com.nimbusds.jose.proc.SecurityContext; 9 | import com.sivalabs.bookstore.ApplicationProperties; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.context.annotation.Configuration; 12 | import org.springframework.security.oauth2.jwt.JwtDecoder; 13 | import org.springframework.security.oauth2.jwt.JwtEncoder; 14 | import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; 15 | import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; 16 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; 17 | import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; 18 | 19 | @Configuration 20 | class JwtSecurityConfig { 21 | private final ApplicationProperties properties; 22 | 23 | JwtSecurityConfig(ApplicationProperties properties) { 24 | this.properties = properties; 25 | } 26 | 27 | @Bean 28 | JwtDecoder jwtDecoder() { 29 | return NimbusJwtDecoder.withPublicKey(properties.jwt().publicKey()).build(); 30 | } 31 | 32 | @Bean 33 | JwtEncoder jwtEncoder() { 34 | JWK jwk = new RSAKey.Builder(properties.jwt().publicKey()) 35 | .privateKey(properties.jwt().privateKey()) 36 | .build(); 37 | JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(jwk)); 38 | return new NimbusJwtEncoder(jwks); 39 | } 40 | 41 | @Bean 42 | public JwtAuthenticationConverter jwtAuthenticationConverter() { 43 | JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); 44 | grantedAuthoritiesConverter.setAuthorityPrefix(""); 45 | grantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); 46 | 47 | JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); 48 | jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); 49 | return jwtAuthenticationConverter; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /k8s/check-ready.ps1: -------------------------------------------------------------------------------- 1 | param() 2 | 3 | $ErrorActionPreference = 'Stop' 4 | 5 | function Info($m) { Write-Host "INFO: $m" } 6 | function Fail($m) { Write-Host "ERROR: $m" -ForegroundColor Red; exit 1 } 7 | 8 | if (-not (Get-Command kubectl -ErrorAction SilentlyContinue)) { Fail 'kubectl not found in PATH' } 9 | 10 | Info "kubectl version:"; kubectl version --client 2>&1 | Write-Host 11 | 12 | $config = Join-Path -Path $PSScriptRoot -ChildPath 'kind/kind-config.yml' 13 | if (Test-Path $config) { 14 | try { 15 | $lines = Get-Content $config -ErrorAction SilentlyContinue 16 | foreach ($l in $lines) { if ($l -match '^\s*name:\s*(\S+)') { $clusterName=$Matches[1]; break } } 17 | } catch { $clusterName = 'kind' } 18 | } else { $clusterName = 'kind' } 19 | 20 | Info "Cluster name: $clusterName" 21 | 22 | Info "Nodes:"; kubectl get nodes -o wide | Write-Host 23 | Info "Pods (all namespaces):"; kubectl get pods -A -o wide | Write-Host 24 | 25 | # Use JSON to reliably inspect pod states and container restart counts 26 | try { 27 | $podsJson = kubectl get pods -A -o json | ConvertFrom-Json 28 | } catch { 29 | Fail "Failed to get pods as JSON: $_" 30 | } 31 | 32 | $suspect = @() 33 | foreach ($item in $podsJson.items) { 34 | $ns = $item.metadata.namespace 35 | $name = $item.metadata.name 36 | $phase = $item.status.phase 37 | $restartCount = 0 38 | if ($null -ne $item.status.containerStatuses) { 39 | foreach ($cs in $item.status.containerStatuses) { $restartCount += ($cs.restartCount -as [int]) } 40 | } 41 | if ($phase -ne 'Running' -or $restartCount -gt 0) { 42 | $suspect += @{ ns = $ns; name = $name; phase = $phase; restarts = $restartCount } 43 | } 44 | } 45 | 46 | if ($suspect.Count -eq 0) { Info 'No suspect pods detected.'; exit 0 } 47 | 48 | Info "Inspecting $($suspect.Count) suspect pods for diagnostics..." 49 | foreach ($p in $suspect) { 50 | $ns = $p.ns; $name = $p.name 51 | Write-Host "`n--- Pod: $ns/$name (phase=$($p.phase), restarts=$($p.restarts)) ---" 52 | kubectl describe pod $name -n $ns | Write-Host 53 | Write-Host "`n--- Logs (current) for $name ---" 54 | kubectl logs $name -n $ns --all-containers | Write-Host 55 | Write-Host "`n--- Logs (previous) for $name (if any) ---" 56 | try { kubectl logs $name -n $ns --all-containers --previous | Write-Host } catch { Write-Host '(no previous logs)' } 57 | } 58 | 59 | exit 0 60 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/config/ApiSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.core.annotation.Order; 6 | import org.springframework.http.HttpMethod; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.security.config.Customizer; 9 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 10 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 11 | import org.springframework.security.config.annotation.web.configurers.CorsConfigurer; 12 | import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; 13 | import org.springframework.security.config.http.SessionCreationPolicy; 14 | import org.springframework.security.web.SecurityFilterChain; 15 | import org.springframework.security.web.authentication.HttpStatusEntryPoint; 16 | 17 | @Configuration 18 | @EnableWebSecurity 19 | class ApiSecurityConfig { 20 | private static final String[] PUBLIC_RESOURCES = { 21 | "/", "/favicon.ico", "/actuator/**", "/error", "/swagger-ui/**", "/v3/api-docs/**", 22 | }; 23 | 24 | @Bean 25 | @Order(1) 26 | SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { 27 | http.securityMatcher("/api/**"); 28 | http.csrf(CsrfConfigurer::disable); 29 | http.cors(CorsConfigurer::disable); 30 | 31 | http.authorizeHttpRequests(c -> c.requestMatchers(PUBLIC_RESOURCES) 32 | .permitAll() 33 | .requestMatchers(HttpMethod.OPTIONS, "/**") 34 | .permitAll() 35 | .requestMatchers("/api/login") 36 | .permitAll() 37 | .requestMatchers(HttpMethod.POST, "/api/users") 38 | .permitAll() 39 | .requestMatchers(HttpMethod.GET, "/api/products", "/api/products/**") 40 | .permitAll() 41 | .anyRequest() 42 | .authenticated()); 43 | 44 | http.oauth2ResourceServer(c -> c.jwt(Customizer.withDefaults())); 45 | http.exceptionHandling(c -> c.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); 46 | http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); 47 | 48 | return http.build(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/catalog/domain/ProductEntity.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.domain; 2 | 3 | import static jakarta.persistence.GenerationType.SEQUENCE; 4 | 5 | import jakarta.persistence.Column; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.GeneratedValue; 8 | import jakarta.persistence.Id; 9 | import jakarta.persistence.SequenceGenerator; 10 | import jakarta.persistence.Table; 11 | import jakarta.validation.constraints.DecimalMin; 12 | import jakarta.validation.constraints.NotEmpty; 13 | import jakarta.validation.constraints.NotNull; 14 | import java.math.BigDecimal; 15 | 16 | @Entity 17 | @Table(name = "products", schema = "catalog") 18 | public class ProductEntity { 19 | @Id 20 | @GeneratedValue(strategy = SEQUENCE, generator = "product_id_generator") 21 | @SequenceGenerator(name = "product_id_generator", sequenceName = "product_id_seq", schema = "catalog") 22 | private Long id; 23 | 24 | @Column(nullable = false, unique = true) 25 | @NotEmpty(message = "Product code is required") private String code; 26 | 27 | @NotEmpty(message = "Product name is required") @Column(nullable = false) 28 | private String name; 29 | 30 | private String description; 31 | 32 | private String imageUrl; 33 | 34 | @NotNull(message = "Product price is required") @DecimalMin("0.1") @Column(nullable = false) 35 | private BigDecimal price; 36 | 37 | public Long getId() { 38 | return id; 39 | } 40 | 41 | public void setId(Long id) { 42 | this.id = id; 43 | } 44 | 45 | public String getCode() { 46 | return code; 47 | } 48 | 49 | public void setCode(String code) { 50 | this.code = code; 51 | } 52 | 53 | public String getName() { 54 | return name; 55 | } 56 | 57 | public void setName(String name) { 58 | this.name = name; 59 | } 60 | 61 | public String getDescription() { 62 | return description; 63 | } 64 | 65 | public void setDescription(String description) { 66 | this.description = description; 67 | } 68 | 69 | public String getImageUrl() { 70 | return imageUrl; 71 | } 72 | 73 | public void setImageUrl(String imageUrl) { 74 | this.imageUrl = imageUrl; 75 | } 76 | 77 | public BigDecimal getPrice() { 78 | return price; 79 | } 80 | 81 | public void setPrice(BigDecimal price) { 82 | this.price = price; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/test/java/com/sivalabs/bookstore/catalog/web/ProductRestControllerTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.catalog.web; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 5 | 6 | import com.sivalabs.bookstore.TestcontainersConfiguration; 7 | import com.sivalabs.bookstore.catalog.ProductDto; 8 | import com.sivalabs.bookstore.common.models.PagedResult; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; 12 | import org.springframework.context.annotation.Import; 13 | import org.springframework.http.HttpStatus; 14 | import org.springframework.modulith.test.ApplicationModuleTest; 15 | import org.springframework.test.context.jdbc.Sql; 16 | import org.springframework.test.web.servlet.assertj.MockMvcTester; 17 | 18 | @ApplicationModuleTest( 19 | webEnvironment = RANDOM_PORT, 20 | extraIncludes = {"config", "users"}) 21 | @Import(TestcontainersConfiguration.class) 22 | @AutoConfigureMockMvc 23 | @Sql("/test-products-data.sql") 24 | class ProductRestControllerTests { 25 | @Autowired 26 | private MockMvcTester mockMvcTester; 27 | 28 | @Test 29 | void shouldGetProducts() { 30 | assertThat(mockMvcTester.get().uri("/api/products")) 31 | .hasStatus(HttpStatus.OK) 32 | .bodyJson() 33 | .convertTo(PagedResult.class) 34 | .satisfies(paged -> { 35 | PagedResult pr = (PagedResult) paged; 36 | assertThat(pr.totalElements()).isEqualTo(15); 37 | assertThat(pr.pageNumber()).isEqualTo(1); 38 | assertThat(pr.totalPages()).isEqualTo(2); 39 | assertThat(pr.isFirst()).isTrue(); 40 | assertThat(pr.isLast()).isFalse(); 41 | assertThat(pr.hasNext()).isTrue(); 42 | assertThat(pr.hasPrevious()).isFalse(); 43 | assertThat(pr.data()).isNotNull(); 44 | }); 45 | } 46 | 47 | @Test 48 | void shouldGetProductByCode() { 49 | assertThat(mockMvcTester.get().uri("/api/products/{code}", "P100")) 50 | .hasStatus(HttpStatus.OK) 51 | .bodyJson() 52 | .convertTo(ProductDto.class) 53 | .satisfies(product -> { 54 | assertThat(product.code()).isEqualTo("P100"); 55 | assertThat(product.name()).isEqualTo("The Hunger Games"); 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/resources/templates/registration.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | Registration 8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 |
17 |
18 | 19 | 22 |
Incorrect data 24 |
25 |
26 |
27 | 28 | 31 |
Incorrect data 33 |
34 |
35 |
36 | 37 | 40 |
Incorrect data 42 |
43 |
44 | 45 |
46 | 47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/web/CartController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web; 2 | 3 | import com.sivalabs.bookstore.catalog.ProductApi; 4 | import com.sivalabs.bookstore.catalog.ProductDto; 5 | import com.sivalabs.bookstore.orders.domain.models.Customer; 6 | import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRefreshView; 7 | import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxRequest; 8 | import jakarta.servlet.http.HttpSession; 9 | import java.util.Map; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | import org.springframework.stereotype.Controller; 13 | import org.springframework.ui.Model; 14 | import org.springframework.web.bind.annotation.GetMapping; 15 | import org.springframework.web.bind.annotation.PostMapping; 16 | import org.springframework.web.bind.annotation.RequestParam; 17 | import org.springframework.web.servlet.View; 18 | import org.springframework.web.servlet.view.FragmentsRendering; 19 | 20 | @Controller 21 | class CartController { 22 | private static final Logger log = LoggerFactory.getLogger(CartController.class); 23 | private final ProductApi productApi; 24 | 25 | CartController(ProductApi productApi) { 26 | this.productApi = productApi; 27 | } 28 | 29 | @PostMapping("/buy") 30 | String addProductToCart(@RequestParam String code, HttpSession session) { 31 | log.info("Adding product code:{} to cart", code); 32 | Cart cart = CartUtil.getCart(session); 33 | ProductDto product = productApi.getByCode(code).orElseThrow(); 34 | cart.setItem(new Cart.LineItem(product.code(), product.name(), product.price(), 1)); 35 | session.setAttribute("cart", cart); 36 | return "redirect:/cart"; 37 | } 38 | 39 | @GetMapping({"/cart"}) 40 | String showCart(Model model, HttpSession session) { 41 | Cart cart = CartUtil.getCart(session); 42 | model.addAttribute("cart", cart); 43 | OrderForm orderForm = new OrderForm(new Customer("", "", ""), ""); 44 | model.addAttribute("orderForm", orderForm); 45 | return "cart"; 46 | } 47 | 48 | @HxRequest 49 | @PostMapping("/update-cart") 50 | View updateCart(@RequestParam String code, @RequestParam int quantity, HttpSession session) { 51 | log.info("Updating cart code:{}, quantity:{}", code, quantity); 52 | Cart cart = CartUtil.getCart(session); 53 | cart.updateItemQuantity(quantity); 54 | session.setAttribute("cart", cart); 55 | boolean refresh = cart.getItem() == null; 56 | if (refresh) { 57 | return new HtmxRefreshView(); 58 | } 59 | return FragmentsRendering.fragment("partials/cart", Map.of("cart", cart)) 60 | .build(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/resources/templates/partials/order-form.html: -------------------------------------------------------------------------------- 1 |
3 |
4 | 5 | 11 |
14 | Incorrect data 15 |
16 |
17 |
18 | 19 | 25 |
28 | Incorrect data 29 |
30 |
31 |
32 | 33 | 39 |
42 | Incorrect data 43 |
44 |
45 |
46 | 47 | 53 |
56 | Incorrect data 57 |
58 |
59 |
60 | 61 |
62 |
-------------------------------------------------------------------------------- /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 | SLEEP_CMD: '{{if eq .GOOS "windows"}}timeout{{else}}sleep{{end}}' 7 | IMAGE_NAME: 'sivaprasadreddy/spring-modular-monolith' 8 | DC_FILE: "compose.yml" 9 | 10 | tasks: 11 | default: 12 | cmds: 13 | - task: test 14 | 15 | test: 16 | deps: [ format ] 17 | cmds: 18 | - "{{.MVNW}} clean verify" 19 | 20 | format: 21 | cmds: 22 | - "{{.MVNW}} spotless:apply" 23 | 24 | build_image: 25 | cmds: 26 | - "{{.MVNW}} clean compile spring-boot:build-image -DskipTests -DdockerImageName={{.IMAGE_NAME}}" 27 | 28 | start: 29 | deps: [ build_image ] 30 | cmds: 31 | - docker compose --profile app -f "{{.DC_FILE}}" up --force-recreate -d 32 | 33 | stop: 34 | cmds: 35 | - docker compose --profile app -f "{{.DC_FILE}}" stop 36 | - docker compose --profile app -f "{{.DC_FILE}}" rm -f 37 | 38 | restart: 39 | cmds: 40 | - task: stop 41 | - task: sleep 42 | - task: start 43 | 44 | # Parent task runs platform-specific subtasks. Non-matching subtasks are skipped. 45 | kind_create: 46 | cmds: 47 | - task: kind_create_windows 48 | - task: kind_create_unix 49 | 50 | kind_create_windows: 51 | platforms: [windows] 52 | cmds: 53 | - powershell -NoProfile -ExecutionPolicy Bypass -File ./k8s/kind/kind-cluster.ps1 create 54 | 55 | kind_create_unix: 56 | cmds: 57 | - ./k8s/kind/kind-cluster.sh create 58 | 59 | kind_create_force: 60 | desc: "Create kind cluster and force hostPort mappings (no build/test)" 61 | cmds: 62 | - task: kind_create_force_windows 63 | - task: kind_create_force_unix 64 | 65 | kind_create_force_windows: 66 | platforms: [windows] 67 | cmds: 68 | - powershell -NoProfile -ExecutionPolicy Bypass -File ./k8s/kind/kind-cluster.ps1 --force-hostports create 69 | 70 | kind_create_force_unix: 71 | cmds: 72 | - ./k8s/kind/kind-cluster.sh --force-hostports create 73 | 74 | kind_destroy: 75 | cmds: 76 | - task: kind_destroy_windows 77 | - task: kind_destroy_unix 78 | 79 | kind_destroy_windows: 80 | platforms: [windows] 81 | cmds: 82 | - powershell -NoProfile -ExecutionPolicy Bypass -File ./k8s/kind/kind-cluster.ps1 destroy 83 | 84 | kind_destroy_unix: 85 | cmds: 86 | - ./k8s/kind/kind-cluster.sh destroy 87 | 88 | k8s_deploy: 89 | cmds: 90 | - kind load docker-image sivaprasadreddy/spring-modular-monolith --name sivalabs-k8s 91 | - kubectl apply -f k8s/manifests/ 92 | 93 | k8s_undeploy: 94 | cmds: 95 | - kubectl delete -f k8s/manifests/ 96 | 97 | sleep: 98 | vars: 99 | DURATION: "{{default 5 .DURATION}}" 100 | cmds: 101 | - "{{.SLEEP_CMD}} {{.DURATION}}" 102 | -------------------------------------------------------------------------------- /src/main/resources/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | BookStore 11 | 12 | 13 | 14 | 15 | 16 |
17 | 59 | 60 |
61 |
62 | 63 |
64 |
65 |
66 | 67 | 68 | 69 |
70 |
71 | 72 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/web/OrderRestController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web; 2 | 3 | import com.sivalabs.bookstore.orders.CreateOrderRequest; 4 | import com.sivalabs.bookstore.orders.CreateOrderResponse; 5 | import com.sivalabs.bookstore.orders.OrderDto; 6 | import com.sivalabs.bookstore.orders.OrderNotFoundException; 7 | import com.sivalabs.bookstore.orders.OrderView; 8 | import com.sivalabs.bookstore.orders.domain.OrderEntity; 9 | import com.sivalabs.bookstore.orders.domain.OrderService; 10 | import com.sivalabs.bookstore.orders.domain.ProductServiceClient; 11 | import com.sivalabs.bookstore.orders.mappers.OrderMapper; 12 | import com.sivalabs.bookstore.users.UserContextUtils; 13 | import jakarta.validation.Valid; 14 | import java.util.List; 15 | import org.slf4j.Logger; 16 | import org.slf4j.LoggerFactory; 17 | import org.springframework.http.HttpStatus; 18 | import org.springframework.web.bind.annotation.GetMapping; 19 | import org.springframework.web.bind.annotation.PathVariable; 20 | import org.springframework.web.bind.annotation.PostMapping; 21 | import org.springframework.web.bind.annotation.RequestBody; 22 | import org.springframework.web.bind.annotation.RequestMapping; 23 | import org.springframework.web.bind.annotation.ResponseStatus; 24 | import org.springframework.web.bind.annotation.RestController; 25 | 26 | @RestController 27 | @RequestMapping("/api/orders") 28 | class OrderRestController { 29 | private static final Logger log = LoggerFactory.getLogger(OrderRestController.class); 30 | 31 | private final OrderService orderService; 32 | private final ProductServiceClient productServiceClient; 33 | 34 | OrderRestController(OrderService orderService, ProductServiceClient productServiceClient) { 35 | this.orderService = orderService; 36 | this.productServiceClient = productServiceClient; 37 | } 38 | 39 | @PostMapping 40 | @ResponseStatus(HttpStatus.CREATED) 41 | CreateOrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) { 42 | var userId = UserContextUtils.getCurrentUserIdOrThrow(); 43 | request = request.withUserId(userId); 44 | productServiceClient.validate(request.item().code(), request.item().price()); 45 | OrderEntity newOrder = OrderMapper.convertToEntity(request); 46 | var savedOrder = orderService.createOrder(newOrder); 47 | return new CreateOrderResponse(savedOrder.getOrderNumber()); 48 | } 49 | 50 | @GetMapping(value = "/{orderNumber}") 51 | OrderDto getOrder(@PathVariable String orderNumber) { 52 | var userId = UserContextUtils.getCurrentUserIdOrThrow(); 53 | log.info("Fetching order by orderNumber: {} and userId: {}", orderNumber, userId); 54 | return orderService 55 | .findOrder(orderNumber, userId) 56 | .map(OrderMapper::convertToDto) 57 | .orElseThrow(() -> OrderNotFoundException.forOrderNumber(orderNumber)); 58 | } 59 | 60 | @GetMapping 61 | List getOrders() { 62 | Long userId = UserContextUtils.getCurrentUserIdOrThrow(); 63 | log.info("Fetching orders for userId: {}", userId); 64 | List orders = orderService.findOrders(userId); 65 | return OrderMapper.convertToOrderViews(orders); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/test/java/com/sivalabs/bookstore/users/web/UserRestControllerTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.web; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 5 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 6 | 7 | import com.sivalabs.bookstore.TestcontainersConfiguration; 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; 12 | import org.springframework.context.annotation.Import; 13 | import org.springframework.http.HttpStatus; 14 | import org.springframework.http.MediaType; 15 | import org.springframework.modulith.test.ApplicationModuleTest; 16 | import org.springframework.test.web.servlet.assertj.MockMvcTester; 17 | 18 | @ApplicationModuleTest( 19 | webEnvironment = RANDOM_PORT, 20 | extraIncludes = {"config"}) 21 | @Import(TestcontainersConfiguration.class) 22 | @AutoConfigureMockMvc 23 | class UserRestControllerTests { 24 | 25 | @Autowired 26 | private MockMvcTester mockMvcTester; 27 | 28 | @Test 29 | @DisplayName("Given valid credentials, user should be able to login successfully") 30 | void shouldLoginSuccessfully() { 31 | String requestBody = """ 32 | { 33 | "email": "siva@gmail.com", 34 | "password": "siva" 35 | } 36 | """; 37 | assertThat(mockMvcTester 38 | .post() 39 | .uri("/api/login") 40 | .contentType(MediaType.APPLICATION_JSON) 41 | .with(csrf()) 42 | .content(requestBody)) 43 | .hasStatus(HttpStatus.OK) 44 | .bodyJson() 45 | .convertTo(UserRestController.LoginResponse.class) 46 | .satisfies(response -> { 47 | assertThat(response.email()).isEqualTo("siva@gmail.com"); 48 | assertThat(response.token()).isNotBlank(); 49 | assertThat(response.name()).isEqualTo("Siva"); 50 | }); 51 | } 52 | 53 | @Test 54 | @DisplayName("Given valid user details, user should be created successfully") 55 | void shouldCreateUserSuccessfully() { 56 | String requestBody = """ 57 | { 58 | "name":"User123", 59 | "email":"user123@gmail.com", 60 | "password":"secret" 61 | } 62 | """; 63 | assertThat(mockMvcTester 64 | .post() 65 | .uri("/api/users") 66 | .contentType(MediaType.APPLICATION_JSON) 67 | .with(csrf()) 68 | .content(requestBody)) 69 | .hasStatus(HttpStatus.CREATED) 70 | .bodyJson() 71 | .convertTo(UserRestController.LoginResponse.class) 72 | .satisfies(response -> { 73 | assertThat(response.email()).isEqualTo("user123@gmail.com"); 74 | assertThat(response.role()).isEqualTo("ROLE_USER"); 75 | assertThat(response.name()).isEqualTo("User123"); 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/resources/templates/order_details.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 |
9 |
10 |

Your Order placed successfully

11 |

Order Number: orderNumber

12 |

Order Status: status

13 |
14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 38 | 39 | 40 |
Product NamePriceQuantitySub Total
namepricequantitySubTotal
36 | Total Amount: totalAmount 37 |
41 | 42 |
43 |
44 | 45 | 51 |
52 |
53 | 54 | 59 |
60 |
61 | 62 | 67 |
68 |
69 | 70 | 75 |
76 | 77 |
78 |
79 |
80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /src/test/resources/test-products-data.sql: -------------------------------------------------------------------------------- 1 | truncate table catalog.products; 2 | 3 | insert into catalog.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 | ; -------------------------------------------------------------------------------- /src/main/resources/db/migration/catalog/V3__catalog_add_books_data.sql: -------------------------------------------------------------------------------- 1 | SET search_path TO catalog; 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 | ; 20 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/users/web/UserRestController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.users.web; 2 | 3 | import static org.springframework.http.HttpStatus.CREATED; 4 | 5 | import com.sivalabs.bookstore.users.domain.CreateUserCmd; 6 | import com.sivalabs.bookstore.users.domain.JwtTokenHelper; 7 | import com.sivalabs.bookstore.users.domain.Role; 8 | import com.sivalabs.bookstore.users.domain.UserService; 9 | import jakarta.validation.Valid; 10 | import jakarta.validation.constraints.Email; 11 | import jakarta.validation.constraints.NotBlank; 12 | import jakarta.validation.constraints.NotEmpty; 13 | import java.time.Instant; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | import org.springframework.http.ResponseEntity; 17 | import org.springframework.security.authentication.AuthenticationManager; 18 | import org.springframework.security.authentication.BadCredentialsException; 19 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 20 | import org.springframework.web.bind.annotation.PostMapping; 21 | import org.springframework.web.bind.annotation.RequestBody; 22 | import org.springframework.web.bind.annotation.RestController; 23 | 24 | @RestController 25 | class UserRestController { 26 | private static final Logger log = LoggerFactory.getLogger(UserRestController.class); 27 | private final AuthenticationManager authManager; 28 | private final UserService userService; 29 | private final JwtTokenHelper jwtTokenHelper; 30 | 31 | UserRestController(AuthenticationManager authManager, UserService userService, JwtTokenHelper jwtTokenHelper) { 32 | this.authManager = authManager; 33 | this.userService = userService; 34 | this.jwtTokenHelper = jwtTokenHelper; 35 | } 36 | 37 | @PostMapping("/api/login") 38 | LoginResponse login(@RequestBody @Valid LoginRequest req) { 39 | log.info("Login request for email: {}", req.email()); 40 | var user = userService 41 | .findByEmail(req.email()) 42 | .orElseThrow(() -> new BadCredentialsException("Invalid credentials")); 43 | var authentication = new UsernamePasswordAuthenticationToken(req.email(), req.password()); 44 | authManager.authenticate(authentication); 45 | var jwtToken = jwtTokenHelper.generateToken(user); 46 | return new LoginResponse( 47 | jwtToken.token(), 48 | jwtToken.expiresAt(), 49 | user.name(), 50 | user.email(), 51 | user.role().name()); 52 | } 53 | 54 | public record LoginRequest( 55 | @NotEmpty(message = "Email is required") @Email(message = "Invalid email address") String email, 56 | 57 | @NotEmpty(message = "Password is required") String password) {} 58 | 59 | public record LoginResponse(String token, Instant expiresAt, String name, String email, String role) {} 60 | 61 | @PostMapping("/api/users") 62 | ResponseEntity createUser(@RequestBody @Valid RegistrationRequest req) { 63 | log.info("Registration request for email: {}", req.email()); 64 | var cmd = new CreateUserCmd(req.name(), req.email(), req.password(), Role.ROLE_USER); 65 | userService.createUser(cmd); 66 | var response = new RegistrationResponse(req.name(), req.email(), Role.ROLE_USER); 67 | return ResponseEntity.status(CREATED.value()).body(response); 68 | } 69 | 70 | public record RegistrationRequest( 71 | @NotBlank(message = "Name is required") String name, 72 | 73 | @NotBlank(message = "Email is required") @Email(message = "Invalid email address") String email, 74 | 75 | @NotBlank(message = "Password is required") String password) {} 76 | 77 | public record RegistrationResponse(String name, String email, Role role) {} 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/web/OrderWebController.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web; 2 | 3 | import com.sivalabs.bookstore.orders.CreateOrderRequest; 4 | import com.sivalabs.bookstore.orders.OrderDto; 5 | import com.sivalabs.bookstore.orders.OrderNotFoundException; 6 | import com.sivalabs.bookstore.orders.OrderView; 7 | import com.sivalabs.bookstore.orders.domain.OrderEntity; 8 | import com.sivalabs.bookstore.orders.domain.OrderService; 9 | import com.sivalabs.bookstore.orders.domain.ProductServiceClient; 10 | import com.sivalabs.bookstore.orders.domain.models.*; 11 | import com.sivalabs.bookstore.orders.mappers.OrderMapper; 12 | import com.sivalabs.bookstore.users.UserContextUtils; 13 | import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRequest; 14 | import jakarta.servlet.http.HttpSession; 15 | import jakarta.validation.Valid; 16 | import java.util.List; 17 | import org.slf4j.Logger; 18 | import org.slf4j.LoggerFactory; 19 | import org.springframework.stereotype.Controller; 20 | import org.springframework.ui.Model; 21 | import org.springframework.validation.BindingResult; 22 | import org.springframework.web.bind.annotation.GetMapping; 23 | import org.springframework.web.bind.annotation.ModelAttribute; 24 | import org.springframework.web.bind.annotation.PathVariable; 25 | import org.springframework.web.bind.annotation.PostMapping; 26 | 27 | @Controller 28 | class OrderWebController { 29 | private static final Logger log = LoggerFactory.getLogger(OrderWebController.class); 30 | 31 | private final OrderService orderService; 32 | private final ProductServiceClient productServiceClient; 33 | 34 | OrderWebController(OrderService orderService, ProductServiceClient productServiceClient) { 35 | this.orderService = orderService; 36 | this.productServiceClient = productServiceClient; 37 | } 38 | 39 | @PostMapping("/orders") 40 | String createOrder( 41 | @ModelAttribute @Valid OrderForm orderForm, BindingResult bindingResult, Model model, HttpSession session) { 42 | Cart cart = CartUtil.getCart(session); 43 | if (bindingResult.hasErrors()) { 44 | model.addAttribute("cart", cart); 45 | return "cart"; 46 | } 47 | Cart.LineItem lineItem = cart.getItem(); 48 | OrderItem orderItem = new OrderItem( 49 | lineItem.getCode(), lineItem.getName(), 50 | lineItem.getPrice(), lineItem.getQuantity()); 51 | var userId = new CreateOrderRequest.UserId(UserContextUtils.getCurrentUserIdOrThrow()); 52 | var request = new CreateOrderRequest(userId, orderForm.customer(), orderForm.deliveryAddress(), orderItem); 53 | productServiceClient.validate(request.item().code(), request.item().price()); 54 | OrderEntity newOrder = OrderMapper.convertToEntity(request); 55 | var savedOrder = orderService.createOrder(newOrder); 56 | session.removeAttribute("cart"); 57 | return "redirect:/orders/" + savedOrder.getOrderNumber(); 58 | } 59 | 60 | @GetMapping("/orders") 61 | String getOrders(Model model, HtmxRequest hxRequest) { 62 | fetchOrders(model); 63 | if (hxRequest.isHtmxRequest()) { 64 | return "partials/orders"; 65 | } 66 | return "orders"; 67 | } 68 | 69 | private void fetchOrders(Model model) { 70 | var userId = UserContextUtils.getCurrentUserIdOrThrow(); 71 | List orders = OrderMapper.convertToOrderViews(orderService.findOrders(userId)); 72 | model.addAttribute("orders", orders); 73 | } 74 | 75 | @GetMapping("/orders/{orderNumber}") 76 | String getOrder(@PathVariable String orderNumber, Model model) { 77 | var userId = UserContextUtils.getCurrentUserIdOrThrow(); 78 | log.info("Fetching order by orderNumber: {} and userId: {}", orderNumber, userId); 79 | OrderDto orderDto = orderService 80 | .findOrder(orderNumber, userId) 81 | .map(OrderMapper::convertToDto) 82 | .orElseThrow(() -> new OrderNotFoundException(orderNumber)); 83 | model.addAttribute("order", orderDto); 84 | return "order_details"; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /k8s/kind/kind-cluster.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 6 | cd "$parent_path" 7 | 8 | # Detect flags (don't consume positional args) 9 | FORCE_HOSTPORTS=0 10 | for arg in "$@"; do 11 | if [ "$arg" = "--force-hostports" ]; then 12 | FORCE_HOSTPORTS=1 13 | fi 14 | done 15 | 16 | function _fail() { 17 | echo "ERROR: $*" 1>&2 18 | exit 1 19 | } 20 | 21 | function create() { 22 | echo "📦 Initializing Kubernetes cluster..." 23 | # Determine target cluster name from config (fallback to 'kind') 24 | CLUSTER_NAME=$(awk '/^name:/{print $2; exit}' kind-config.yml 2>/dev/null || echo "kind") 25 | # Check for host port conflicts that would prevent hostPort mappings 26 | PORTS_TO_CHECK="80 443 30090 30091 30092" 27 | conflict=0 28 | if [ "$FORCE_HOSTPORTS" -eq 1 ]; then 29 | echo "Forcing hostPort mappings (override requested) - skipping host port checks" 30 | else 31 | for p in $PORTS_TO_CHECK; do 32 | if command -v ss >/dev/null 2>&1; then 33 | if ss -ltn | awk '{print $4}' | grep -q ":$p$"; then 34 | echo "Host port $p appears in use; will skip extra port mappings" 35 | conflict=1 36 | break 37 | fi 38 | elif command -v lsof >/dev/null 2>&1; then 39 | if lsof -iTCP -sTCP:LISTEN -P -n | grep -q ":$p"; then 40 | echo "Host port $p appears in use; will skip extra port mappings" 41 | conflict=1 42 | break 43 | fi 44 | fi 45 | done 46 | fi 47 | 48 | # If the cluster already exists, reuse it 49 | if kind get clusters | grep -q "^${CLUSTER_NAME}$"; then 50 | echo "Cluster '${CLUSTER_NAME}' already exists — reusing" 51 | else 52 | if [ "$conflict" -eq 1 ]; then 53 | echo "Creating kind cluster named '${CLUSTER_NAME}' without host port mappings (conflict detected)" 54 | kind create cluster --name "${CLUSTER_NAME}" || _fail "kind create cluster failed" 55 | else 56 | kind create cluster --config kind-config.yml || _fail "kind create cluster failed" 57 | fi 58 | fi 59 | 60 | echo "\n-----------------------------------------------------\n" 61 | echo "🔌 Installing NGINX Ingress..." 62 | kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml || _fail "kubectl apply failed" 63 | 64 | echo "\n-----------------------------------------------------\n" 65 | echo "⌛ Waiting for NGINX Ingress to be ready..." 66 | sleep 10 67 | if ! kubectl wait --namespace ingress-nginx \ 68 | --for=condition=ready pod \ 69 | --selector=app.kubernetes.io/component=controller \ 70 | --timeout=180s; then 71 | kubectl get pods -n ingress-nginx || true 72 | _fail "NGINX Ingress pods did not become ready within timeout" 73 | fi 74 | 75 | echo "\n" 76 | echo "⛵ Happy Sailing!" 77 | } 78 | 79 | function destroy() { 80 | echo "🏴‍☠️ Destroying Kubernetes cluster..." 81 | # Determine cluster name from config (fallback to 'kind') 82 | CLUSTER_NAME=$(awk '/^name:/{print $2; exit}' kind-config.yml 2>/dev/null || echo "kind") 83 | kind delete cluster --name "${CLUSTER_NAME}" || _fail "kind delete cluster failed" 84 | } 85 | 86 | function help() { 87 | echo "Usage: ./kind-cluster [--force-hostports] create|destroy" 88 | echo " --force-hostports Force using hostPort mappings even if host ports appear in use" 89 | } 90 | 91 | action="help" 92 | 93 | # Handle the --force-hostports flag and action separately 94 | if [[ "$#" != "0" ]]; then 95 | if [[ "$1" == "--force-hostports" ]]; then 96 | if [[ "$#" -gt 1 ]]; then 97 | action="${@:2}" # Take all arguments after --force-hostports 98 | fi 99 | else 100 | action="$@" 101 | fi 102 | fi 103 | 104 | case "$action" in 105 | "create"|"destroy"|"help") 106 | $action 107 | ;; 108 | *) 109 | help 110 | ;; 111 | esac 112 | -------------------------------------------------------------------------------- /src/main/java/com/sivalabs/bookstore/orders/domain/OrderEntity.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.domain; 2 | 3 | import com.sivalabs.bookstore.orders.domain.models.Customer; 4 | import com.sivalabs.bookstore.orders.domain.models.OrderItem; 5 | import com.sivalabs.bookstore.orders.domain.models.OrderStatus; 6 | import jakarta.persistence.AttributeOverride; 7 | import jakarta.persistence.AttributeOverrides; 8 | import jakarta.persistence.Column; 9 | import jakarta.persistence.Embedded; 10 | import jakarta.persistence.Entity; 11 | import jakarta.persistence.EnumType; 12 | import jakarta.persistence.Enumerated; 13 | import jakarta.persistence.GeneratedValue; 14 | import jakarta.persistence.GenerationType; 15 | import jakarta.persistence.Id; 16 | import jakarta.persistence.SequenceGenerator; 17 | import jakarta.persistence.Table; 18 | import java.time.LocalDateTime; 19 | import java.time.ZoneId; 20 | 21 | @Entity 22 | @Table(name = "orders", schema = "orders") 23 | public class OrderEntity { 24 | 25 | @Id 26 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_id_generator") 27 | @SequenceGenerator(name = "order_id_generator", sequenceName = "order_id_seq", schema = "orders") 28 | private Long id; 29 | 30 | @Column(nullable = false, unique = true) 31 | private String orderNumber; 32 | 33 | @Column(name = "user_id", nullable = false) 34 | private Long userId; 35 | 36 | @Embedded 37 | @AttributeOverrides( 38 | value = { 39 | @AttributeOverride(name = "name", column = @Column(name = "customer_name")), 40 | @AttributeOverride(name = "email", column = @Column(name = "customer_email")), 41 | @AttributeOverride(name = "phone", column = @Column(name = "customer_phone")) 42 | }) 43 | private Customer customer; 44 | 45 | @Column(nullable = false) 46 | private String deliveryAddress; 47 | 48 | @Embedded 49 | @AttributeOverrides( 50 | value = { 51 | @AttributeOverride(name = "code", column = @Column(name = "product_code")), 52 | @AttributeOverride(name = "name", column = @Column(name = "product_name")), 53 | @AttributeOverride(name = "price", column = @Column(name = "product_price")), 54 | @AttributeOverride(name = "quantity", column = @Column(name = "quantity")) 55 | }) 56 | private OrderItem orderItem; 57 | 58 | @Enumerated(EnumType.STRING) 59 | private OrderStatus status; 60 | 61 | @Column(name = "created_at", nullable = false, updatable = false) 62 | private LocalDateTime createdAt = LocalDateTime.now(ZoneId.systemDefault()); 63 | 64 | @Column(name = "updated_at") 65 | private LocalDateTime updatedAt; 66 | 67 | public OrderEntity() {} 68 | 69 | public OrderEntity( 70 | Long id, 71 | String orderNumber, 72 | Long userId, 73 | Customer customer, 74 | String deliveryAddress, 75 | OrderItem orderItem, 76 | OrderStatus status, 77 | LocalDateTime createdAt, 78 | LocalDateTime updatedAt) { 79 | this.id = id; 80 | this.orderNumber = orderNumber; 81 | this.userId = userId; 82 | this.customer = customer; 83 | this.deliveryAddress = deliveryAddress; 84 | this.orderItem = orderItem; 85 | this.status = status; 86 | this.createdAt = createdAt; 87 | this.updatedAt = updatedAt; 88 | } 89 | 90 | public Long getId() { 91 | return id; 92 | } 93 | 94 | public void setId(Long id) { 95 | this.id = id; 96 | } 97 | 98 | public String getOrderNumber() { 99 | return orderNumber; 100 | } 101 | 102 | public void setOrderNumber(String orderNumber) { 103 | this.orderNumber = orderNumber; 104 | } 105 | 106 | public Long getUserId() { 107 | return userId; 108 | } 109 | 110 | public void setUserId(Long userId) { 111 | this.userId = userId; 112 | } 113 | 114 | public Customer getCustomer() { 115 | return customer; 116 | } 117 | 118 | public void setCustomer(Customer customer) { 119 | this.customer = customer; 120 | } 121 | 122 | public String getDeliveryAddress() { 123 | return deliveryAddress; 124 | } 125 | 126 | public void setDeliveryAddress(String deliveryAddress) { 127 | this.deliveryAddress = deliveryAddress; 128 | } 129 | 130 | public OrderItem getOrderItem() { 131 | return orderItem; 132 | } 133 | 134 | public void setOrderItem(OrderItem orderItem) { 135 | this.orderItem = orderItem; 136 | } 137 | 138 | public OrderStatus getStatus() { 139 | return status; 140 | } 141 | 142 | public void setStatus(OrderStatus status) { 143 | this.status = status; 144 | } 145 | 146 | public LocalDateTime getCreatedAt() { 147 | return createdAt; 148 | } 149 | 150 | public void setCreatedAt(LocalDateTime createdAt) { 151 | this.createdAt = createdAt; 152 | } 153 | 154 | public LocalDateTime getUpdatedAt() { 155 | return updatedAt; 156 | } 157 | 158 | public void setUpdatedAt(LocalDateTime updatedAt) { 159 | this.updatedAt = updatedAt; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /k8s/kind/kind-cluster.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | Cross-platform helper for kind cluster create/destroy on Windows (PowerShell) 3 | - Detects host port conflicts and falls back to creating a cluster without hostPort mappings 4 | - Parses cluster name from `kind-config.yml` (fallback 'kind') 5 | - Reuses existing cluster if present 6 | - Installs ingress-nginx and waits for controller readiness 7 | #> 8 | 9 | 10 | $ErrorActionPreference = 'Stop' 11 | 12 | $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition 13 | Set-Location $ScriptDir 14 | 15 | # Parse flags from automatic $args 16 | $ForceHostPorts = $false 17 | foreach ($a in $args) { 18 | if ($a -eq '--force-hostports') { $ForceHostPorts = $true } 19 | } 20 | 21 | function Fail([string]$msg) { 22 | Write-Host "ERROR: $msg" -ForegroundColor Red 23 | exit 1 24 | } 25 | 26 | function Get-LastExit() { 27 | $v = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue 28 | if ($null -eq $v) { return $null } 29 | return $v.Value 30 | } 31 | 32 | function Check-LastExit([string]$msg) { 33 | $c = Get-LastExit 34 | if ($null -eq $c) { Fail "$msg (no exit code)" } 35 | if ($c -ne 0) { Fail "$msg (exit code $c)" } 36 | } 37 | 38 | function Read-ClusterName() { 39 | $name = 'kind' 40 | if (Test-Path 'kind-config.yml') { 41 | try { 42 | $lines = Get-Content 'kind-config.yml' -ErrorAction SilentlyContinue 43 | foreach ($l in $lines) { 44 | if ($l -match '^\s*name:\s*(\S+)') { $name = $Matches[1]; break } 45 | } 46 | } catch { } 47 | } 48 | return $name 49 | } 50 | 51 | function Test-Port([int]$port) { 52 | # Use Test-NetConnection when available, otherwise fallback to netstat parse 53 | if (Get-Command Test-NetConnection -ErrorAction SilentlyContinue) { 54 | $r = Test-NetConnection -ComputerName '127.0.0.1' -Port $port -WarningAction SilentlyContinue 55 | return ($r -and $r.TcpTestSucceeded) 56 | } else { 57 | $net = netstat -an 2>$null 58 | return ($net -and ($net -match "LISTENING.*:$port\b")) 59 | } 60 | } 61 | 62 | function Create-Cluster { 63 | Write-Host 'Initializing Kubernetes cluster...' 64 | $portsToCheck = 80,443,30090,30091,30092 65 | $conflict = $false 66 | if ($ForceHostPorts) { 67 | Write-Host 'Forcing hostPort mappings (override requested) - skipping host port checks' 68 | } else { 69 | foreach ($p in $portsToCheck) { 70 | try { 71 | if (Test-Port -port $p) { 72 | Write-Host "Host port $p appears in use; will skip hostPort mappings" 73 | $conflict = $true 74 | break 75 | } 76 | } catch { } 77 | } 78 | } 79 | 80 | $clusterName = Read-ClusterName 81 | Write-Host ("DEBUG: resolved clusterName='{0}'" -f $clusterName) 82 | 83 | # Show kind location for debugging 84 | $kindCmd = Get-Command kind -ErrorAction SilentlyContinue 85 | if ($null -eq $kindCmd) { Fail 'kind binary not found in PATH' } 86 | Write-Host "DEBUG: kind: $($kindCmd.Path)" 87 | 88 | # Print current clusters (debug) 89 | try { 90 | & kind get clusters 2>$null | ForEach-Object { Write-Host "KIND: $_" } 91 | } catch { } 92 | 93 | $exists = & kind get clusters 2>$null | Select-String "^$clusterName$" -Quiet 94 | if ($exists) { 95 | Write-Host ("Cluster '{0}' exists - reusing" -f $clusterName) 96 | } else { 97 | if ($conflict) { 98 | Write-Host ("Creating kind cluster named '{0}' without host port mappings (conflict detected)" -f $clusterName) 99 | kind create cluster --name $clusterName 100 | Check-LastExit 'kind create cluster failed' 101 | } else { 102 | if (-not (Test-Path 'kind-config.yml')) { Fail 'kind-config.yml not found' } 103 | kind create cluster --config kind-config.yml 104 | Check-LastExit 'kind create cluster failed' 105 | } 106 | } 107 | 108 | Write-Host 'Installing NGINX Ingress controller (provider: kind)' 109 | kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml 110 | Check-LastExit 'kubectl apply failed' 111 | 112 | Write-Host 'Waiting for NGINX Ingress controller to be ready (timeout 180s)' 113 | Start-Sleep -Seconds 5 114 | kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=180s 115 | $code = Get-LastExit 116 | if ($null -eq $code -or $code -ne 0) { 117 | Write-Host '--- Ingress pod status ---' 118 | kubectl get pods -n ingress-nginx -o wide | Out-String | Write-Host 119 | Write-Host '--- Pod describe for troubleshooting ---' 120 | $pod = kubectl get pods -n ingress-nginx -o name | Select-Object -First 1 121 | if ($pod) { kubectl describe $pod -n ingress-nginx | Out-String | Write-Host } 122 | Fail 'NGINX Ingress pods did not become ready within timeout' 123 | } 124 | 125 | Write-Host 'Kind cluster ready.' 126 | } 127 | 128 | function Destroy-Cluster { 129 | Write-Host 'Destroying Kubernetes cluster...' 130 | $clusterName = Read-ClusterName 131 | kind delete cluster --name $clusterName 132 | Check-LastExit 'kind delete cluster failed' 133 | } 134 | 135 | function Show-Help { 136 | Write-Host 'Usage: .\kind-cluster.ps1 [--force-hostports] create|destroy' 137 | Write-Host ' --force-hostports Force using hostPort mappings even if host ports appear in use' 138 | } 139 | 140 | $action = 'help' 141 | foreach ($a in $args) { 142 | if ($a -eq 'create' -or $a -eq 'destroy') { $action = $a; break } 143 | } 144 | 145 | switch ($action.ToLower()) { 146 | 'create' { Create-Cluster } 147 | 'destroy' { Destroy-Cluster } 148 | default { Show-Help } 149 | } 150 | -------------------------------------------------------------------------------- /src/test/java/com/sivalabs/bookstore/orders/web/OrderRestControllerTests.java: -------------------------------------------------------------------------------- 1 | package com.sivalabs.bookstore.orders.web; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.mockito.BDDMockito.given; 5 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 6 | 7 | import com.sivalabs.bookstore.TestcontainersConfiguration; 8 | import com.sivalabs.bookstore.catalog.ProductApi; 9 | import com.sivalabs.bookstore.catalog.ProductDto; 10 | import com.sivalabs.bookstore.orders.CreateOrderRequest; 11 | import com.sivalabs.bookstore.orders.OrderDto; 12 | import com.sivalabs.bookstore.orders.domain.OrderEntity; 13 | import com.sivalabs.bookstore.orders.domain.OrderService; 14 | import com.sivalabs.bookstore.orders.domain.models.Customer; 15 | import com.sivalabs.bookstore.orders.domain.models.OrderCreatedEvent; 16 | import com.sivalabs.bookstore.orders.domain.models.OrderItem; 17 | import com.sivalabs.bookstore.orders.mappers.OrderMapper; 18 | import com.sivalabs.bookstore.users.domain.JwtTokenHelper; 19 | import com.sivalabs.bookstore.users.domain.UserDto; 20 | import com.sivalabs.bookstore.users.domain.UserService; 21 | import java.math.BigDecimal; 22 | import java.util.Optional; 23 | import org.junit.jupiter.api.BeforeEach; 24 | import org.junit.jupiter.api.Test; 25 | import org.springframework.beans.factory.annotation.Autowired; 26 | import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; 27 | import org.springframework.context.annotation.Import; 28 | import org.springframework.http.HttpHeaders; 29 | import org.springframework.http.HttpStatus; 30 | import org.springframework.http.MediaType; 31 | import org.springframework.modulith.test.ApplicationModuleTest; 32 | import org.springframework.modulith.test.AssertablePublishedEvents; 33 | import org.springframework.test.context.bean.override.mockito.MockitoBean; 34 | import org.springframework.test.web.servlet.assertj.MockMvcTester; 35 | 36 | @ApplicationModuleTest( 37 | webEnvironment = RANDOM_PORT, 38 | extraIncludes = {"config", "users"}) 39 | @Import(TestcontainersConfiguration.class) 40 | @AutoConfigureMockMvc 41 | class OrderRestControllerTests { 42 | @Autowired 43 | private MockMvcTester mockMvcTester; 44 | 45 | @Autowired 46 | private OrderService orderService; 47 | 48 | @Autowired 49 | private JwtTokenHelper jwtTokenHelper; 50 | 51 | @MockitoBean 52 | ProductApi productApi; 53 | 54 | @Autowired 55 | private UserService userService; 56 | 57 | @BeforeEach 58 | void setUp() { 59 | ProductDto product = new ProductDto("P100", "The Hunger Games", "", null, new BigDecimal("34.0")); 60 | given(productApi.getByCode("P100")).willReturn(Optional.of(product)); 61 | } 62 | 63 | private String createJwtToken(String email) { 64 | UserDto userDto = userService.findByEmail(email).orElseThrow(); 65 | return jwtTokenHelper.generateToken(userDto).token(); 66 | } 67 | 68 | @Test 69 | void shouldCreateOrderSuccessfully(AssertablePublishedEvents events) { 70 | assertThat(mockMvcTester 71 | .post() 72 | .uri("/api/orders") 73 | .contentType(MediaType.APPLICATION_JSON) 74 | .header(HttpHeaders.AUTHORIZATION, "Bearer " + createJwtToken("siva@gmail.com")) 75 | .content(""" 76 | { 77 | "customer": { 78 | "name": "Siva", 79 | "email": "siva123@gmail.com", 80 | "phone": "9876523456" 81 | }, 82 | "deliveryAddress": "James, Bangalore, India", 83 | "item":{ 84 | "code": "P100", 85 | "name": "The Hunger Games", 86 | "price": 34.0, 87 | "quantity": 1 88 | } 89 | } 90 | """)) 91 | .hasStatus(HttpStatus.CREATED); 92 | 93 | assertThat(events) 94 | .contains(OrderCreatedEvent.class) 95 | .matching(e -> e.customer().email(), "siva123@gmail.com") 96 | .matching(OrderCreatedEvent::productCode, "P100"); 97 | } 98 | 99 | @Test 100 | void shouldReturnNotFoundWhenOrderIdNotExist() { 101 | assertThat(mockMvcTester 102 | .get() 103 | .uri("/api/orders/{orderNumber}", "non-existing-order-id") 104 | .header(HttpHeaders.AUTHORIZATION, "Bearer " + createJwtToken("siva@gmail.com"))) 105 | .hasStatus(HttpStatus.NOT_FOUND); 106 | } 107 | 108 | @Test 109 | void shouldGetOrderSuccessfully() { 110 | OrderEntity orderEntity = buildOrderEntity(2L); 111 | OrderEntity savedOrder = orderService.createOrder(orderEntity); 112 | 113 | assertThat(mockMvcTester 114 | .get() 115 | .uri("/api/orders/{orderNumber}", savedOrder.getOrderNumber()) 116 | .header(HttpHeaders.AUTHORIZATION, "Bearer " + createJwtToken("siva@gmail.com"))) 117 | .hasStatus(HttpStatus.OK) 118 | .bodyJson() 119 | .convertTo(OrderDto.class) 120 | .satisfies(order -> { 121 | assertThat(order.orderNumber()).isEqualTo(savedOrder.getOrderNumber()); 122 | }); 123 | } 124 | 125 | @Test 126 | void shouldGetOrdersSuccessfully() { 127 | OrderEntity orderEntity = buildOrderEntity(2L); 128 | orderService.createOrder(orderEntity); 129 | 130 | assertThat(mockMvcTester 131 | .get() 132 | .uri("/api/orders") 133 | .header(HttpHeaders.AUTHORIZATION, "Bearer " + createJwtToken("siva@gmail.com"))) 134 | .hasStatus(HttpStatus.OK); 135 | } 136 | 137 | private static OrderEntity buildOrderEntity(Long userId) { 138 | CreateOrderRequest request = buildCreateOrderRequest(userId); 139 | return OrderMapper.convertToEntity(request); 140 | } 141 | 142 | private static CreateOrderRequest buildCreateOrderRequest(Long userId) { 143 | OrderItem item = new OrderItem("P100", "The Hunger Games", new BigDecimal("34.0"), 1); 144 | return new CreateOrderRequest( 145 | new CreateOrderRequest.UserId(userId), 146 | new Customer("Siva", "siva@gmail.com", "77777777"), 147 | "Siva, Hyderabad, India", 148 | item); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spring-modular-monolith 2 | An e-commerce application following Modular Monolith architecture using [Spring Modulith](https://spring.io/projects/spring-modulith). 3 | The goal of this application is to demonstrate various features of Spring Modulith with a practical application. 4 | 5 | ![bookstore-modulith.png](docs/bookstore-modulith.png) 6 | 7 | This application follows modular monolith architecture with the following modules: 8 | 9 | * **Common:** This module contains the code that is shared by all modules. 10 | * **Catalog:** This module manages the catalog of products and store data in `catalog` schema. 11 | * **Orders:** This module implements the order management and store the data in `orders` schema. 12 | * **Inventory:** This module implements the inventory management and store the data in `inventory` schema. 13 | * **Notifications:** This module handles the events published by other modules and sends notifications to the interested parties. 14 | 15 | **Goals:** 16 | * Implement each module as independently as possible. 17 | * Prefer event-driven communication instead of direct module dependency wherever applicable. 18 | * Store data managed by each module in an isolated manner by using different schema or database. 19 | * Each module should be testable by loading only module-specific components. 20 | 21 | **Module communication:** 22 | 23 | * **Common** module is an OPEN module that can be used by other modules. 24 | * **Orders** module invokes the **Catalog** module public API to validate the order details 25 | * When an Order is successfully created, **Orders** module publishes **"OrderCreatedEvent"** 26 | * The **"OrderCreatedEvent"** will also be published to external broker like RabbitMQ. Other applications may consume and process those events. 27 | * **Inventory** module consumes "OrderCreatedEvent" and updates the stock level for the products. 28 | * **Notifications** module consumes "OrderCreatedEvent" and sends an order confirmation email to the customer. 29 | 30 | ## Prerequisites 31 | * JDK 25 32 | * Docker and Docker Compose 33 | * Your favourite IDE (Recommended: [IntelliJ IDEA](https://www.jetbrains.com/idea/)) 34 | 35 | Install JDK, Maven, Gradle, etc using [SDKMAN](https://sdkman.io/) 36 | 37 | ```shell 38 | $ curl -s "https://get.sdkman.io" | bash 39 | $ source "$HOME/.sdkman/bin/sdkman-init.sh" 40 | $ sdk install java 25-tem 41 | $ sdk install maven 42 | ``` 43 | 44 | Task is a task runner that we can use to run any arbitrary commands in easier way. 45 | 46 | ```shell 47 | $ brew install go-task 48 | (or) 49 | $ go install github.com/go-task/task/v3/cmd/task@latest 50 | ``` 51 | 52 | On Linux you can install `task` using your package manager or via `go install`: 53 | 54 | ```shell 55 | # Debian/Ubuntu (using apt via the official binary repo): 56 | sudo sh -c "wget -qO - https://taskfile.dev/install.sh | bash" 57 | 58 | # or using snap (if available): 59 | sudo snap install task --classic 60 | 61 | # or build from source with Go: 62 | go install github.com/go-task/task/v3/cmd/task@latest 63 | ``` 64 | 65 | On Windows you can use Chocolatey, Scoop, or `go install`: 66 | 67 | ```powershell 68 | # Chocolatey 69 | choco install gotask -y 70 | 71 | # Scoop 72 | scoop install task 73 | 74 | # or build from source with Go (requires Go installed and in PATH): 75 | go install github.com/go-task/task/v3/cmd/task@latest 76 | ``` 77 | 78 | Verify the prerequisites 79 | 80 | ```shell 81 | $ java -version 82 | $ docker info 83 | $ docker compose version 84 | $ task --version 85 | ``` 86 | 87 | ## Using `task` to perform various tasks: 88 | 89 | ```shell 90 | # Run tests 91 | $ task test 92 | 93 | # Automatically format code using spotless-maven-plugin 94 | $ task format 95 | 96 | # Build docker image 97 | $ task build_image 98 | 99 | # Run application in docker container 100 | $ task start 101 | $ task stop 102 | $ task restart 103 | ``` 104 | 105 | * Application URL: http://localhost:8080 106 | * Actuator URL: http://localhost:8080/actuator 107 | * Actuator URL for modulith: http://localhost:8080/actuator/modulith 108 | * RabbitMQ Admin URL: http://localhost:15672 (Credentials: guest/guest) 109 | * Zipkin URL: http://localhost:9411 110 | 111 | ## Deploying on k8s cluster 112 | * [Install kubectl](https://kubernetes.io/docs/tasks/tools/) 113 | * [Install kind](https://kind.sigs.k8s.io/docs/user/quick-start/) 114 | 115 | ```shell 116 | $ brew install kubectl 117 | $ brew install kind 118 | ``` 119 | 120 | Create a KinD cluster and deploy an app. 121 | 122 | ```shell 123 | # Create KinD cluster 124 | $ task kind_create 125 | 126 | # deploy app to kind cluster 127 | $ task k8s_deploy 128 | 129 | # undeploy app 130 | $ task k8s_undeploy 131 | 132 | # Destroy KinD cluster 133 | $ task kind_destroy 134 | ``` 135 | 136 | * Application URL: http://localhost:30090 137 | * RabbitMQ Admin URL: http://localhost:30091 (Credentials: guest/guest) 138 | * Zipkin URL: http://localhost:30092 139 | 140 | ### Kubernetes Deployment Notes and Troubleshooting 141 | 142 | These notes explain how `task kind_create` / `task k8s_deploy` behave and common troubleshooting steps you can use when deploying the app to a local KinD cluster. 143 | 144 | - Host-port mappings and fallback: 145 | - The Kind configuration (`k8s/kind/kind-config.yml`) maps host ports (80, 443, 30090-30092) into the control-plane node so services are accessible from the host. 146 | - If any of those host ports are already in use on your machine (for example a local web server or another Kubernetes runtime), the PowerShell and shell helpers will detect the conflict and create the Kind cluster without hostPort mappings. 147 | - When host-port mappings are skipped, the app is still deployed inside the cluster — you'll need to port-forward or use `kubectl port-forward` (or access services via the cluster network) to reach them from your host. 148 | 149 | - Postgres readiness and app startup: 150 | - The application depends on the Postgres pod becoming Ready before it can run Flyway migrations and bring up the Spring Boot application. 151 | - Sometimes the app starts before Postgres finishes initialization, which causes connection errors and a crash loop. In this setup the app will typically retry on pod restart (and succeed) once Postgres is Ready. 152 | - If the app repeatedly fails with messages like `Connection to spring-modular-monolith-postgres-svc:5432 refused`, check the Postgres pod logs (see commands below) and ensure it reaches `database system is ready to accept connections`. 153 | 154 | - Quick troubleshooting commands 155 | - Show pods and their status: 156 | - `kubectl get pods -A -o wide` 157 | - Show the nodes 158 | - `kubectl get nodes` 159 | - Describe a failing pod to see events: 160 | - `kubectl describe pod -n ` 161 | - Show logs for a container (current + previous): 162 | - `kubectl logs -c ` 163 | - `kubectl logs -c --previous` 164 | - If Postgres is not Ready, inspect its logs: 165 | - `kubectl logs -c postgres` 166 | - If hostPort mappings were skipped and you want to access the app locally: 167 | - Port-forward the service to localhost: `kubectl port-forward svc/spring-modular-monolith-svc 8080:8080` 168 | - Then visit: `http://localhost:8080` 169 | 170 | - If you want hostPort mappings enforced 171 | - Free the host ports (80, 443, 30090-30092) on your machine, or edit `k8s/kind/kind-config.yml` to remove the hostPort entries. 172 | - Then recreate the cluster with `task kind_destroy` followed by `task kind_create`. 173 | 174 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.4 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | 82 | $MAVEN_M2_PATH = "$HOME/.m2" 83 | if ($env:MAVEN_USER_HOME) { 84 | $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" 85 | } 86 | 87 | if (-not (Test-Path -Path $MAVEN_M2_PATH)) { 88 | New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null 89 | } 90 | 91 | $MAVEN_WRAPPER_DISTS = $null 92 | if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { 93 | $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" 94 | } else { 95 | $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" 96 | } 97 | 98 | $MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" 99 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 100 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 101 | 102 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 103 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 104 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 105 | exit $? 106 | } 107 | 108 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 109 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 110 | } 111 | 112 | # prepare tmp dir 113 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 114 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 115 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 116 | trap { 117 | if ($TMP_DOWNLOAD_DIR.Exists) { 118 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 119 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 120 | } 121 | } 122 | 123 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 124 | 125 | # Download and Install Apache Maven 126 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 127 | Write-Verbose "Downloading from: $distributionUrl" 128 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 129 | 130 | $webclient = New-Object System.Net.WebClient 131 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 132 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 133 | } 134 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 135 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 136 | 137 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 138 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 139 | if ($distributionSha256Sum) { 140 | if ($USE_MVND) { 141 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 142 | } 143 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 144 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 145 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 146 | } 147 | } 148 | 149 | # unzip and move 150 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 151 | 152 | # Find the actual extracted directory name (handles snapshots where filename != directory name) 153 | $actualDistributionDir = "" 154 | 155 | # First try the expected directory name (for regular distributions) 156 | $expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" 157 | $expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" 158 | if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { 159 | $actualDistributionDir = $distributionUrlNameMain 160 | } 161 | 162 | # If not found, search for any directory with the Maven executable (for snapshots) 163 | if (!$actualDistributionDir) { 164 | Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { 165 | $testPath = Join-Path $_.FullName "bin/$MVN_CMD" 166 | if (Test-Path -Path $testPath -PathType Leaf) { 167 | $actualDistributionDir = $_.Name 168 | } 169 | } 170 | } 171 | 172 | if (!$actualDistributionDir) { 173 | Write-Error "Could not find Maven distribution directory in extracted archive" 174 | } 175 | 176 | Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" 177 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null 178 | try { 179 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 180 | } catch { 181 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 182 | Write-Error "fail to move MAVEN_HOME" 183 | } 184 | } finally { 185 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 186 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 187 | } 188 | 189 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 190 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.4 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | scriptDir="$(dirname "$0")" 109 | scriptName="$(basename "$0")" 110 | 111 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 112 | while IFS="=" read -r key value; do 113 | case "${key-}" in 114 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 115 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 116 | esac 117 | done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" 118 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 119 | 120 | case "${distributionUrl##*/}" in 121 | maven-mvnd-*bin.*) 122 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 123 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 124 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 125 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 126 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 127 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 128 | *) 129 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 130 | distributionPlatform=linux-amd64 131 | ;; 132 | esac 133 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 134 | ;; 135 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 136 | *) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 137 | esac 138 | 139 | # apply MVNW_REPOURL and calculate MAVEN_HOME 140 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 141 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 142 | distributionUrlName="${distributionUrl##*/}" 143 | distributionUrlNameMain="${distributionUrlName%.*}" 144 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 145 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 146 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 147 | 148 | exec_maven() { 149 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 150 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 151 | } 152 | 153 | if [ -d "$MAVEN_HOME" ]; then 154 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 155 | exec_maven "$@" 156 | fi 157 | 158 | case "${distributionUrl-}" in 159 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 160 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 161 | esac 162 | 163 | # prepare tmp dir 164 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 165 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 166 | trap clean HUP INT TERM EXIT 167 | else 168 | die "cannot create temp dir" 169 | fi 170 | 171 | mkdir -p -- "${MAVEN_HOME%/*}" 172 | 173 | # Download and Install Apache Maven 174 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 175 | verbose "Downloading from: $distributionUrl" 176 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 177 | 178 | # select .zip or .tar.gz 179 | if ! command -v unzip >/dev/null; then 180 | distributionUrl="${distributionUrl%.zip}.tar.gz" 181 | distributionUrlName="${distributionUrl##*/}" 182 | fi 183 | 184 | # verbose opt 185 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 186 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 187 | 188 | # normalize http auth 189 | case "${MVNW_PASSWORD:+has-password}" in 190 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 191 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 192 | esac 193 | 194 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 195 | verbose "Found wget ... using wget" 196 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 197 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 198 | verbose "Found curl ... using curl" 199 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 200 | elif set_java_home; then 201 | verbose "Falling back to use Java to download" 202 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 203 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 204 | cat >"$javaSource" <<-END 205 | public class Downloader extends java.net.Authenticator 206 | { 207 | protected java.net.PasswordAuthentication getPasswordAuthentication() 208 | { 209 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 210 | } 211 | public static void main( String[] args ) throws Exception 212 | { 213 | setDefault( new Downloader() ); 214 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 215 | } 216 | } 217 | END 218 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 219 | verbose " - Compiling Downloader.java ..." 220 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 221 | verbose " - Running Downloader.java ..." 222 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 223 | fi 224 | 225 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 226 | if [ -n "${distributionSha256Sum-}" ]; then 227 | distributionSha256Result=false 228 | if [ "$MVN_CMD" = mvnd.sh ]; then 229 | echo "Checksum validation is not supported for maven-mvnd." >&2 230 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 231 | exit 1 232 | elif command -v sha256sum >/dev/null; then 233 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then 234 | distributionSha256Result=true 235 | fi 236 | elif command -v shasum >/dev/null; then 237 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 238 | distributionSha256Result=true 239 | fi 240 | else 241 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 242 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 243 | exit 1 244 | fi 245 | if [ $distributionSha256Result = false ]; then 246 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 247 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 248 | exit 1 249 | fi 250 | fi 251 | 252 | # unzip and move 253 | if command -v unzip >/dev/null; then 254 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 255 | else 256 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 257 | fi 258 | 259 | # Find the actual extracted directory name (handles snapshots where filename != directory name) 260 | actualDistributionDir="" 261 | 262 | # First try the expected directory name (for regular distributions) 263 | if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then 264 | if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then 265 | actualDistributionDir="$distributionUrlNameMain" 266 | fi 267 | fi 268 | 269 | # If not found, search for any directory with the Maven executable (for snapshots) 270 | if [ -z "$actualDistributionDir" ]; then 271 | # enable globbing to iterate over items 272 | set +f 273 | for dir in "$TMP_DOWNLOAD_DIR"/*; do 274 | if [ -d "$dir" ]; then 275 | if [ -f "$dir/bin/$MVN_CMD" ]; then 276 | actualDistributionDir="$(basename "$dir")" 277 | break 278 | fi 279 | fi 280 | done 281 | set -f 282 | fi 283 | 284 | if [ -z "$actualDistributionDir" ]; then 285 | verbose "Contents of $TMP_DOWNLOAD_DIR:" 286 | verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" 287 | die "Could not find Maven distribution directory in extracted archive" 288 | fi 289 | 290 | verbose "Found extracted Maven distribution directory: $actualDistributionDir" 291 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" 292 | mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 293 | 294 | clean || : 295 | exec_maven "$@" 296 | --------------------------------------------------------------------------------