From 06ce179ab512c1feed1f9371155d99fd8a7b9a3a Mon Sep 17 00:00:00 2001 From: "MSI\\guanxin" Date: Fri, 30 Jan 2026 10:22:39 +0800 Subject: [PATCH 1/6] feat: user service impl and architecture --- .vscode/settings.json | 11 ++- apps/grpc/modules/aggregator-service/pom.xml | 46 +++++++++ .../AggregatorServiceApplication.java | 13 +++ .../src/main/resources/application.properties | 9 ++ .../gx/aggregator/tests/UserTradeTest.java | 5 + apps/grpc/modules/main-app/pom.xml | 75 +++++++++++++++ .../java/com/gx/grpc/GrpcApplication.java | 0 .../main/resources/application-dev.properties | 0 .../src/main/resources/application.properties | 0 apps/grpc/modules/proto-common/pom.xml | 80 ++++++++++++++++ .../src/main/proto/common/common.proto | 14 +++ .../src/main/proto/stock-service.proto | 34 +++++++ .../src/main/proto/user-service.proto | 55 +++++++++++ apps/grpc/modules/user-service/pom.xml | 88 +++++++++++++++++ .../com/gx/user/UserServiceApplication.java | 13 +++ .../com/gx/user/entity/PortfolioItem.java | 22 +++++ .../main/java/com/gx/user/entity/User.java | 16 ++++ .../InsufficientBalanceException.java | 8 ++ .../exceptions/UnkownTickerException.java | 10 ++ .../user/exceptions/UnkownUserException.java | 10 ++ .../repository/PortfolioItemRepository.java | 17 ++++ .../gx/user/repository/UserRepository.java | 11 +++ .../service/StockTradeRequestHandler.java | 70 ++++++++++++++ .../UserInformationRequestHandler.java | 27 ++++++ .../java/com/gx/user/service/UserService.java | 38 ++++++++ .../advice/GrpcExceptionAdviceHandler.java | 72 ++++++++++++++ .../java/com/gx/user/util/EntityMapper.java | 53 +++++++++++ .../main/resources/application-dev.properties | 18 ++++ .../src/main/resources/application.properties | 18 ++++ .../user-service/src/main/resources/data.sql | 23 +++++ .../com/gx/user/tests/UserServiceTest.java | 7 ++ apps/grpc/pom.xml | 94 ++++++++++++++++++- apps/grpc/project.json | 40 ++++++-- package.json | 7 +- pom.xml | 15 +++ 35 files changed, 1003 insertions(+), 16 deletions(-) create mode 100644 apps/grpc/modules/aggregator-service/pom.xml create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/AggregatorServiceApplication.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/resources/application.properties create mode 100644 apps/grpc/modules/aggregator-service/src/main/test/java/com/gx/aggregator/tests/UserTradeTest.java create mode 100644 apps/grpc/modules/main-app/pom.xml rename apps/grpc/{ => modules/main-app}/src/main/java/com/gx/grpc/GrpcApplication.java (100%) rename apps/grpc/{ => modules/main-app}/src/main/resources/application-dev.properties (100%) rename apps/grpc/{ => modules/main-app}/src/main/resources/application.properties (100%) create mode 100644 apps/grpc/modules/proto-common/pom.xml create mode 100644 apps/grpc/modules/proto-common/src/main/proto/common/common.proto create mode 100644 apps/grpc/modules/proto-common/src/main/proto/stock-service.proto create mode 100644 apps/grpc/modules/proto-common/src/main/proto/user-service.proto create mode 100644 apps/grpc/modules/user-service/pom.xml create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/UserServiceApplication.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/PortfolioItem.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/User.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/InsufficientBalanceException.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/UnkownTickerException.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/UnkownUserException.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/PortfolioItemRepository.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/UserRepository.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/service/StockTradeRequestHandler.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/service/UserInformationRequestHandler.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/service/UserService.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/service/advice/GrpcExceptionAdviceHandler.java create mode 100644 apps/grpc/modules/user-service/src/main/java/com/gx/user/util/EntityMapper.java create mode 100644 apps/grpc/modules/user-service/src/main/resources/application-dev.properties create mode 100644 apps/grpc/modules/user-service/src/main/resources/application.properties create mode 100644 apps/grpc/modules/user-service/src/main/resources/data.sql create mode 100644 apps/grpc/modules/user-service/src/main/test/java/com/gx/user/tests/UserServiceTest.java diff --git a/.vscode/settings.json b/.vscode/settings.json index d53ecaf..5add823 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,9 @@ { - "java.compile.nullAnalysis.mode": "automatic", - "java.configuration.updateBuildConfiguration": "automatic" -} \ No newline at end of file + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic", + "editor.formatOnSave": true, + "[java]": { + "editor.defaultFormatter": "redhat.java", + "editor.formatOnSave": true + } +} diff --git a/apps/grpc/modules/aggregator-service/pom.xml b/apps/grpc/modules/aggregator-service/pom.xml new file mode 100644 index 0000000..32bef5c --- /dev/null +++ b/apps/grpc/modules/aggregator-service/pom.xml @@ -0,0 +1,46 @@ + + + + 4.0.0 + + com.guan + grpc-app + 0.0.1-SNAPSHOT + ../../pom.xml + + + aggregator-service + + + + org.springframework.boot + spring-boot-starter-web + + + + com.google.protobuf + protobuf-java-util + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.gx.aggregator.AggregatorServiceApplication + false + + + + + + \ No newline at end of file diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/AggregatorServiceApplication.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/AggregatorServiceApplication.java new file mode 100644 index 0000000..308eadb --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/AggregatorServiceApplication.java @@ -0,0 +1,13 @@ +package com.gx.aggregator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AggregatorServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AggregatorServiceApplication.class, args); + } + +} \ No newline at end of file diff --git a/apps/grpc/modules/aggregator-service/src/main/resources/application.properties b/apps/grpc/modules/aggregator-service/src/main/resources/application.properties new file mode 100644 index 0000000..7eaf237 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/resources/application.properties @@ -0,0 +1,9 @@ +# Aggregator Service - HTTP + gRPC Client +server.port=3000 + +# gRPC Client Configuration +grpc.client.user-service.address=static://localhost:9091 +grpc.client.user-service.negotiationType=PLAINTEXT + +# Logging +logging.level.com.gx.aggregator=INFO diff --git a/apps/grpc/modules/aggregator-service/src/main/test/java/com/gx/aggregator/tests/UserTradeTest.java b/apps/grpc/modules/aggregator-service/src/main/test/java/com/gx/aggregator/tests/UserTradeTest.java new file mode 100644 index 0000000..85e08a9 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/test/java/com/gx/aggregator/tests/UserTradeTest.java @@ -0,0 +1,5 @@ +package com.gx.aggregator.tests; +public class UserTradeTest { + + +} \ No newline at end of file diff --git a/apps/grpc/modules/main-app/pom.xml b/apps/grpc/modules/main-app/pom.xml new file mode 100644 index 0000000..0a51154 --- /dev/null +++ b/apps/grpc/modules/main-app/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + com.guan + grpc-app + 0.0.1-SNAPSHOT + ../../pom.xml + + + main-app + jar + Main Application + + + + + com.guan + proto-common + ${project.version} + + + com.guan + user-service + ${project.version} + + + com.guan + aggregator-service + ${project.version} + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.h2database + h2 + runtime + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.gx.grpc.GrpcApplication + false + + + + + repackage + + + + + + + + diff --git a/apps/grpc/src/main/java/com/gx/grpc/GrpcApplication.java b/apps/grpc/modules/main-app/src/main/java/com/gx/grpc/GrpcApplication.java similarity index 100% rename from apps/grpc/src/main/java/com/gx/grpc/GrpcApplication.java rename to apps/grpc/modules/main-app/src/main/java/com/gx/grpc/GrpcApplication.java diff --git a/apps/grpc/src/main/resources/application-dev.properties b/apps/grpc/modules/main-app/src/main/resources/application-dev.properties similarity index 100% rename from apps/grpc/src/main/resources/application-dev.properties rename to apps/grpc/modules/main-app/src/main/resources/application-dev.properties diff --git a/apps/grpc/src/main/resources/application.properties b/apps/grpc/modules/main-app/src/main/resources/application.properties similarity index 100% rename from apps/grpc/src/main/resources/application.properties rename to apps/grpc/modules/main-app/src/main/resources/application.properties diff --git a/apps/grpc/modules/proto-common/pom.xml b/apps/grpc/modules/proto-common/pom.xml new file mode 100644 index 0000000..311c43b --- /dev/null +++ b/apps/grpc/modules/proto-common/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + com.guan + grpc-app + 0.0.1-SNAPSHOT + ../../pom.xml + + + proto-common + Proto Common + Shared protobuf definitions + + + + + io.grpc + grpc-stub + + + io.grpc + grpc-protobuf + + + + + com.google.protobuf + protobuf-java + + + com.google.protobuf + protobuf-java-util + + + + + javax.annotation + javax.annotation-api + 1.3.2 + + + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + ${protobuf-maven-plugin.version} + + com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + ${project.basedir}/src/main/proto + + + + + compile + compile-custom + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + + diff --git a/apps/grpc/modules/proto-common/src/main/proto/common/common.proto b/apps/grpc/modules/proto-common/src/main/proto/common/common.proto new file mode 100644 index 0000000..94b2bca --- /dev/null +++ b/apps/grpc/modules/proto-common/src/main/proto/common/common.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package common; + +option java_multiple_files = true; +option java_package = "com.gx.common"; + +enum Ticker { + UNKNOWN = 0; + APPLE = 1; + GOOGLE = 2; + AMAZON = 3; + MICROSOFT = 4; +} \ No newline at end of file diff --git a/apps/grpc/modules/proto-common/src/main/proto/stock-service.proto b/apps/grpc/modules/proto-common/src/main/proto/stock-service.proto new file mode 100644 index 0000000..3c8969b --- /dev/null +++ b/apps/grpc/modules/proto-common/src/main/proto/stock-service.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package stock; + +import "google/protobuf/empty.proto"; +import "common/common.proto"; + +option java_multiple_files = true; +option java_package = "com.gx.stock"; + +message PriceUpdate { + common.Ticker ticker = 1; + int32 price = 2; +} + +message StockPriceRequest { + common.Ticker ticker = 1; +} + +message StockPriceResponse { + common.Ticker ticker = 1; + int32 price = 2; +} + +// Service for providing stock prices. +service StockService { + + // Unary API to get the current price for a specific ticker. + rpc GetStockPrice(StockPriceRequest) returns (StockPriceResponse); + + // Server streaming API to subscribe to real-time price updates. + rpc GetPriceUpdates(google.protobuf.Empty) returns (stream PriceUpdate); + +} \ No newline at end of file diff --git a/apps/grpc/modules/proto-common/src/main/proto/user-service.proto b/apps/grpc/modules/proto-common/src/main/proto/user-service.proto new file mode 100644 index 0000000..636c624 --- /dev/null +++ b/apps/grpc/modules/proto-common/src/main/proto/user-service.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package user; + +import "common/common.proto"; + +option java_multiple_files = true; +option java_package = "com.gx.user"; + +message UserInformationRequest{ + int32 user_id = 1; +} + +message UserInformation { + int32 user_id = 1; + string name = 2; + int32 balance = 3; + repeated Holding holdings = 4; +} + +message Holding { + common.Ticker ticker = 1; + int32 quantity = 2; +} + +enum TradeAction { + BUY = 0; + SELL = 1; +} + +message StockTradeRequest { + int32 user_id = 1; + common.Ticker ticker = 2; + int32 price = 3; + int32 quantity = 4; + TradeAction action = 5; +} + +message StockTradeResponse { + int32 user_id = 1; + common.Ticker ticker = 2; + int32 price = 3; + int32 quantity = 4; + TradeAction action = 5; + int32 total_price = 6; + int32 balance = 7; +} + +service UserService { + + rpc GetUserInformation(UserInformationRequest) returns (UserInformation); + + rpc TradeStock(StockTradeRequest) returns (StockTradeResponse); + +} \ No newline at end of file diff --git a/apps/grpc/modules/user-service/pom.xml b/apps/grpc/modules/user-service/pom.xml new file mode 100644 index 0000000..324348e --- /dev/null +++ b/apps/grpc/modules/user-service/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + com.guan + grpc-app + 0.0.1-SNAPSHOT + ../../pom.xml + + + user-service + + + + com.guan + proto-common + ${project.version} + + + + io.grpc + grpc-stub + + + io.grpc + grpc-protobuf + + + org.springframework.grpc + spring-grpc-spring-boot-starter + 1.0.1 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + io.grpc + grpc-testing + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.gx.user.UserServiceApplication + false + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + com.google.protobuf:protoc:3.25.1:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:1.60.0:exe:${os.detected.classifier} + ${project.basedir}/../proto-common/src/main/proto + + + + + compile + compile-custom + + + + + + + + \ No newline at end of file diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/UserServiceApplication.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/UserServiceApplication.java new file mode 100644 index 0000000..c347236 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/UserServiceApplication.java @@ -0,0 +1,13 @@ +package com.gx.user; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class UserServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(UserServiceApplication.class, args); + } + +} \ No newline at end of file diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/PortfolioItem.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/PortfolioItem.java new file mode 100644 index 0000000..658e2c2 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/PortfolioItem.java @@ -0,0 +1,22 @@ +package com.gx.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Data; + +@Entity +@Data +@Builder +public class PortfolioItem { + @Id + @GeneratedValue + private Integer id; + + @Column(name = "customer_id") + private Integer userId; + private String ticker; + private Integer quantity; +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/User.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/User.java new file mode 100644 index 0000000..1213f96 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/User.java @@ -0,0 +1,16 @@ +package com.gx.user.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; + +@Entity +@Table(name = "customer") +@Data +public class User { + @Id + private Integer id; + private String name; + private Integer balance; +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/InsufficientBalanceException.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/InsufficientBalanceException.java new file mode 100644 index 0000000..3ef7391 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/InsufficientBalanceException.java @@ -0,0 +1,8 @@ +package com.gx.user.exceptions; + +public class InsufficientBalanceException extends RuntimeException { + public InsufficientBalanceException(Integer userId) { + super("Insufficient balance for user with ID: " + userId); + } + +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/UnkownTickerException.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/UnkownTickerException.java new file mode 100644 index 0000000..ad84611 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/UnkownTickerException.java @@ -0,0 +1,10 @@ +package com.gx.user.exceptions; + +public class UnkownTickerException extends RuntimeException { + private static final String MESSAGE = "Ticker %s not found in user's portfolio."; + + public UnkownTickerException(String ticker) { + super(String.format(MESSAGE, ticker)); + } + +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/UnkownUserException.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/UnkownUserException.java new file mode 100644 index 0000000..af54292 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/exceptions/UnkownUserException.java @@ -0,0 +1,10 @@ +package com.gx.user.exceptions; + +public class UnkownUserException extends RuntimeException { + private static final String MESSAGE = "User with id %d not found."; + + public UnkownUserException(Integer userId) { + super(String.format(MESSAGE, userId)); + } + +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/PortfolioItemRepository.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/PortfolioItemRepository.java new file mode 100644 index 0000000..f6ccaa1 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/PortfolioItemRepository.java @@ -0,0 +1,17 @@ +package com.gx.user.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import com.gx.user.entity.PortfolioItem; +import com.gx.common.Ticker; + +@Repository +public interface PortfolioItemRepository extends CrudRepository { + List findAllByUserId(Integer userId); + + Optional findByUserIdAndTicker(Integer userId, Ticker ticker); +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/UserRepository.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/UserRepository.java new file mode 100644 index 0000000..7bf02f7 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.gx.user.repository; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import com.gx.user.entity.User; + +@Repository +public interface UserRepository extends CrudRepository { + +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/StockTradeRequestHandler.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/StockTradeRequestHandler.java new file mode 100644 index 0000000..59cb9f3 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/StockTradeRequestHandler.java @@ -0,0 +1,70 @@ +package com.gx.user.service; + +import org.springframework.stereotype.Service; + +import com.gx.common.Ticker; +import com.gx.user.StockTradeRequest; +import com.gx.user.StockTradeResponse; +import com.gx.user.exceptions.InsufficientBalanceException; +import com.gx.user.exceptions.UnkownTickerException; +import com.gx.user.exceptions.UnkownUserException; +import com.gx.user.repository.PortfolioItemRepository; +import com.gx.user.repository.UserRepository; +import com.gx.user.util.EntityMapper; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StockTradeRequestHandler { + private final UserRepository userRepository; + private final PortfolioItemRepository portfolioItemRepository; + private final EntityMapper entityMapper; + + @Transactional + public StockTradeResponse buyStock(StockTradeRequest request) { + this.validateTickerInPortfolio(request.getTicker()); + var user = this.userRepository.findById(request.getUserId()) + .orElseThrow(() -> new UnkownUserException(request.getUserId())); + var totalPrice = request.getQuantity() * request.getPrice(); + this.validateUserBalance(request.getUserId(), user.getBalance(), totalPrice); + + user.setBalance(user.getBalance() - totalPrice); + + this.portfolioItemRepository.findByUserIdAndTicker(request.getUserId(), request.getTicker()) + .ifPresentOrElse(item -> item.setQuantity(item.getQuantity() + request.getQuantity()), () -> { + var newItem = this.entityMapper.toPortfolioItem(request); + this.portfolioItemRepository.save(newItem); + }); + return this.entityMapper.toStockTradeResponse(request, user.getBalance()); + } + + @Transactional + public StockTradeResponse sellStock(StockTradeRequest request) { + this.validateTickerInPortfolio(request.getTicker()); + var user = this.userRepository.findById(request.getUserId()) + .orElseThrow(() -> new UnkownUserException(request.getUserId())); + + var portfolioItem = this.portfolioItemRepository.findByUserIdAndTicker(request.getUserId(), request.getTicker()) + .filter(item -> item.getQuantity() >= request.getQuantity()) + .orElseThrow(() -> new InsufficientBalanceException(user.getId())); + + var totalPrice = request.getQuantity() * request.getPrice(); + user.setBalance(user.getBalance() + totalPrice); + portfolioItem.setQuantity(portfolioItem.getQuantity() - request.getQuantity()); + return this.entityMapper.toStockTradeResponse(request, user.getBalance()); + } + + private void validateTickerInPortfolio(Ticker ticker) { + if (Ticker.UNKNOWN.equals(ticker)) { + throw new UnkownTickerException(ticker.name()); + } + } + + private void validateUserBalance(Integer userId, Integer userBalance, Integer totalPrice) { + if (userBalance < totalPrice) { + throw new InsufficientBalanceException(userId); + } + } +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/UserInformationRequestHandler.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/UserInformationRequestHandler.java new file mode 100644 index 0000000..33e4a1b --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/UserInformationRequestHandler.java @@ -0,0 +1,27 @@ +package com.gx.user.service; + +import org.springframework.stereotype.Service; + +import com.gx.user.UserInformation; +import com.gx.user.UserInformationRequest; +import com.gx.user.exceptions.UnkownUserException; +import com.gx.user.repository.PortfolioItemRepository; +import com.gx.user.repository.UserRepository; +import com.gx.user.util.EntityMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserInformationRequestHandler { + private final UserRepository userRepository; + private final PortfolioItemRepository portfolioItemRepository; + private final EntityMapper entityMapper; + + public UserInformation getUserInformation(UserInformationRequest request) { + var user = this.userRepository.findById(request.getUserId()) + .orElseThrow(() -> new UnkownUserException(request.getUserId())); + var portfolioItems = this.portfolioItemRepository.findAllByUserId(request.getUserId()); + return this.entityMapper.toUserInformation(user, portfolioItems); + } +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/UserService.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/UserService.java new file mode 100644 index 0000000..fd5f085 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/UserService.java @@ -0,0 +1,38 @@ +package com.gx.user.service; + +import org.springframework.grpc.server.service.GrpcService; +import com.gx.user.UserServiceGrpc; +import io.grpc.stub.StreamObserver; +import lombok.RequiredArgsConstructor; + +import com.gx.user.UserInformationRequest; +import com.gx.user.UserInformation; +import com.gx.user.StockTradeRequest; +import com.gx.user.StockTradeResponse; +import com.gx.user.TradeAction; + +@GrpcService +@RequiredArgsConstructor +public class UserService extends UserServiceGrpc.UserServiceImplBase { + private final UserInformationRequestHandler userInformationRequestHandler; + private final StockTradeRequestHandler stockTradeRequestHandler; + + @Override + public void getUserInformation(UserInformationRequest request, StreamObserver responseObserver) { + var response = userInformationRequestHandler.getUserInformation(request); + responseObserver.onNext(response); + responseObserver.onCompleted(); + } + + @Override + public void tradeStock(StockTradeRequest request, StreamObserver responseObserver) { + var response = switch (request.getAction()) { + case BUY -> stockTradeRequestHandler.buyStock(request); + case SELL -> stockTradeRequestHandler.sellStock(request); + case UNRECOGNIZED -> + throw new IllegalArgumentException("Unrecognized trade action: " + request.getAction()); + }; + responseObserver.onNext(response); + responseObserver.onCompleted(); + } +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/advice/GrpcExceptionAdviceHandler.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/advice/GrpcExceptionAdviceHandler.java new file mode 100644 index 0000000..4902d10 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/advice/GrpcExceptionAdviceHandler.java @@ -0,0 +1,72 @@ +package com.gx.user.service.advice; + +import org.springframework.stereotype.Component; + +import com.gx.user.exceptions.InsufficientBalanceException; +import com.gx.user.exceptions.UnkownTickerException; +import com.gx.user.exceptions.UnkownUserException; + +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; + +@Component +public class GrpcExceptionAdviceHandler implements ServerInterceptor { + + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + + ServerCall.Listener listener = next.startCall(call, headers); + return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(listener) { + @Override + public void onHalfClose() { + try { + super.onHalfClose(); + } catch (Exception ex) { + handleException(call, ex); + } + } + + @Override + public void onMessage(ReqT message) { + try { + super.onMessage(message); + } catch (Exception ex) { + handleException(call, ex); + } + } + + @Override + public void onReady() { + try { + super.onReady(); + } catch (Exception ex) { + handleException(call, ex); + } + } + }; + } + + private void handleException(ServerCall call, Exception ex) { + Status status; + + if (ex instanceof IllegalArgumentException) { + status = Status.INVALID_ARGUMENT.withDescription(ex.getMessage()); + } else if (ex instanceof UnkownUserException) { + status = Status.NOT_FOUND.withDescription(ex.getMessage()); + } else if (ex instanceof UnkownTickerException) { + status = Status.NOT_FOUND.withDescription(ex.getMessage()); + } else if (ex instanceof InsufficientBalanceException) { + status = Status.FAILED_PRECONDITION.withDescription(ex.getMessage()); + } else { + status = Status.INTERNAL.withDescription("Error: " + ex.getMessage()); + } + + call.close(status, new Metadata()); + } + +} diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/util/EntityMapper.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/util/EntityMapper.java new file mode 100644 index 0000000..143e238 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/util/EntityMapper.java @@ -0,0 +1,53 @@ +package com.gx.user.util; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.gx.common.Ticker; +import com.gx.user.Holding; +import com.gx.user.StockTradeRequest; +import com.gx.user.StockTradeResponse; +import com.gx.user.entity.PortfolioItem; +import com.gx.user.entity.User; +import com.gx.user.UserInformation; + +@Service +public class EntityMapper { + public UserInformation toUserInformation(User user, List portfolioItems) { + var holdings = portfolioItems.stream() + .map(item -> Holding.newBuilder() + .setTicker(Ticker.valueOf(item.getTicker())) + .setQuantity(item.getQuantity()) + .build()) + .collect(Collectors.toList()); + // Assuming UserInformation has a builder and a method to set holdings + return UserInformation.newBuilder() + .setUserId(user.getId()) + .setName(user.getName()) + .setBalance(user.getBalance()) + .addAllHoldings(holdings) + .build(); + } + + public PortfolioItem toPortfolioItem(StockTradeRequest request) { + return PortfolioItem.builder() + .userId(request.getUserId()) + .ticker(request.getTicker().name()) + .quantity(request.getQuantity()) + .build(); + } + + public StockTradeResponse toStockTradeResponse(StockTradeRequest request, int newBalance) { + return StockTradeResponse.newBuilder() + .setUserId(request.getUserId()) + .setTicker(request.getTicker()) + .setQuantity(request.getQuantity()) + .setPrice(request.getPrice()) + .setTotalPrice(request.getPrice() * request.getQuantity()) + .setBalance(newBalance) + .setAction(request.getAction()) + .build(); + } +} diff --git a/apps/grpc/modules/user-service/src/main/resources/application-dev.properties b/apps/grpc/modules/user-service/src/main/resources/application-dev.properties new file mode 100644 index 0000000..696ab7f --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/resources/application-dev.properties @@ -0,0 +1,18 @@ +# User Service - Dev Profile +server.port=9090 +spring.grpc.server.port=9092 + +# Database +spring.datasource.url=jdbc:h2:mem:userdb +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=none +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console + +# Logging +logging.level.com.gx.user=DEBUG + +# SQL Initialization +spring.sql.init.mode=always +spring.jpa.defer-datasource-initialization=true diff --git a/apps/grpc/modules/user-service/src/main/resources/application.properties b/apps/grpc/modules/user-service/src/main/resources/application.properties new file mode 100644 index 0000000..36d3828 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/resources/application.properties @@ -0,0 +1,18 @@ +# User Service - gRPC Server +server.port=9090 +spring.grpc.server.port=9092 + +# Database +spring.datasource.url=jdbc:h2:mem:userdb +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=none +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console + +# Logging +logging.level.com.gx.user=INFO + +# SQL Initialization +spring.sql.init.mode=always +spring.jpa.defer-datasource-initialization=true \ No newline at end of file diff --git a/apps/grpc/modules/user-service/src/main/resources/data.sql b/apps/grpc/modules/user-service/src/main/resources/data.sql new file mode 100644 index 0000000..1e808e9 --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/resources/data.sql @@ -0,0 +1,23 @@ +DROP TABLE IF EXISTS customer; +DROP TABLE IF EXISTS portfolio_item; + +-- intentionally naming this table as customer as "user" has some issues +CREATE TABLE customer ( + id int AUTO_INCREMENT primary key, + name VARCHAR(50), + balance int +); + +CREATE TABLE portfolio_item ( + id int AUTO_INCREMENT primary key, + customer_id int, + ticker VARCHAR(10), + quantity int, + foreign key (customer_id) references customer(id) +); + +insert into customer(name, balance) + values + ('Sam', 10000), + ('Mike', 10000), + ('John', 10000); \ No newline at end of file diff --git a/apps/grpc/modules/user-service/src/main/test/java/com/gx/user/tests/UserServiceTest.java b/apps/grpc/modules/user-service/src/main/test/java/com/gx/user/tests/UserServiceTest.java new file mode 100644 index 0000000..09c94dc --- /dev/null +++ b/apps/grpc/modules/user-service/src/main/test/java/com/gx/user/tests/UserServiceTest.java @@ -0,0 +1,7 @@ +package com.gx.user.tests; + +public class UserServiceTest { + + + +} \ No newline at end of file diff --git a/apps/grpc/pom.xml b/apps/grpc/pom.xml index d82b056..65dcc9d 100644 --- a/apps/grpc/pom.xml +++ b/apps/grpc/pom.xml @@ -9,25 +9,73 @@ ../../pom.xml grpc-app - jar + pom gRPC Application gRPC application module + + modules/proto-common + modules/user-service + modules/aggregator-service + + + + + org.springframework.grpc + spring-grpc-spring-boot-starter + 1.0.1 + + + + + org.springframework.boot + spring-boot-starter-web + + + + + io.grpc + grpc-netty-shaded + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + io.grpc grpc-services + + - org.springframework.grpc - spring-grpc-spring-boot-starter + com.google.protobuf + protobuf-java + + + com.google.protobuf + protobuf-java-util + + + + + javax.annotation + javax.annotation-api + 1.3.2 + org.projectlombok lombok true + + org.springframework.boot spring-boot-starter-test @@ -38,10 +86,47 @@ spring-grpc-test test + + io.grpc + grpc-testing + test + + + + + kr.motd.maven + os-maven-plugin + ${os-maven-plugin.version} + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + ${protobuf-maven-plugin.version} + false + + com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + ${project.basedir}/src/main/proto + + + + + compile + compile-custom + + + + + + org.apache.maven.plugins maven-compiler-plugin @@ -54,10 +139,13 @@ + + org.springframework.boot spring-boot-maven-plugin + true org.projectlombok diff --git a/apps/grpc/project.json b/apps/grpc/project.json index 28ec07d..200276e 100644 --- a/apps/grpc/project.json +++ b/apps/grpc/project.json @@ -7,27 +7,49 @@ "serve": { "executor": "nx:run-commands", "options": { - "command": "mvn spring-boot:run -Dspring-boot.run.profiles=dev", - "cwd": "{workspaceRoot}/apps/grpc" + "commands": [ + "mvn spring-boot:run -pl modules/user-service -Dspring-boot.run.profiles=dev -DskipTests", + "mvn spring-boot:run -pl modules/aggregator-service -Dspring-boot.run.profiles=dev -DskipTests" + ], + "cwd": "{workspaceRoot}/apps/grpc", + "parallel": true }, "configurations": { "development": { - "command": "mvn spring-boot:run -Dspring-boot.run.profiles=development", - "cwd": "{workspaceRoot}/apps/grpc" + "commands": [ + "mvn spring-boot:run -pl modules/user-service -Dspring-boot.run.profiles=development -DskipTests", + "mvn spring-boot:run -pl modules/aggregator-service -Dspring-boot.run.profiles=development -DskipTests" + ] }, "production": { - "command": "mvn spring-boot:run -Dspring-boot.run.profiles=production", - "cwd": "{workspaceRoot}/apps/grpc" + "commands": [ + "mvn spring-boot:run -pl modules/user-service -Dspring-boot.run.profiles=production -DskipTests", + "mvn spring-boot:run -pl modules/aggregator-service -Dspring-boot.run.profiles=production -DskipTests" + ] } } }, + "serve:user": { + "executor": "nx:run-commands", + "options": { + "command": "mvn spring-boot:run -pl modules/user-service -Dspring-boot.run.profiles=dev -DskipTests", + "cwd": "{workspaceRoot}/apps/grpc" + } + }, + "serve:aggregator": { + "executor": "nx:run-commands", + "options": { + "command": "mvn spring-boot:run -pl modules/aggregator-service -Dspring-boot.run.profiles=dev -DskipTests", + "cwd": "{workspaceRoot}/apps/grpc" + } + }, "build": { "executor": "nx:run-commands", "options": { - "command": "mvn spring-boot:build-image", + "command": "mvn clean install -DskipTests", "cwd": "{workspaceRoot}/apps/grpc" }, - "outputs": ["{projectRoot}/target"] + "outputs": ["{projectRoot}/modules/*/target"] }, "test": { "executor": "nx:run-commands", @@ -44,5 +66,5 @@ } } }, - "tags": ["backend", "java", "spring"] + "tags": ["backend", "java", "spring", "grpc", "microservices"] } diff --git a/package.json b/package.json index 5f36b2c..50af46d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,12 @@ "version": "0.0.0", "license": "MIT", "scripts": { - "dev-grpc": "cross-env NODE_ENV=dev PORT=3000 nx serve grpc" + "grpc:serve": "nx run grpc:serve", + "grpc:serve:user": "nx run grpc:serve:user", + "grpc:serve:aggregator": "nx run grpc:serve:aggregator", + "grpc:build": "nx run grpc:build", + "grpc:test": "nx run grpc:test", + "grpc:clean": "nx run grpc:clean" }, "private": true, "devDependencies": { diff --git a/pom.xml b/pom.xml index 7f337cd..824fa29 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,10 @@ 25 1.77.1 4.33.2 + 4.33.2 1.0.1 + 1.7.1 + 0.6.1 @@ -32,6 +35,18 @@ pom import + + io.grpc + grpc-bom + ${grpc.version} + pom + import + + + com.google.protobuf + protobuf-java + ${protobuf-java.version} + From e9882b51f5360db98ea289420f173bcf13837e8c Mon Sep 17 00:00:00 2001 From: "MSI\\guanxin" Date: Fri, 30 Jan 2026 18:24:01 +0800 Subject: [PATCH 2/6] feat: modular monolith app trading simlulation app --- apps/grpc/modules/aggregator-service/pom.xml | 19 + .../com/gx/aggregator/config/GrpcConfig.java | 127 +++++ .../controller/StockController.java | 25 + .../controller/TradeController.java | 25 + .../aggregator/controller/UserController.java | 23 + .../ApplicationExceptionAdviceHandler.java | 24 + .../controller/dto/PriceUpdateDto.java | 11 + .../service/PriceUpdateListener.java | 79 +++ .../PriceUpdateSubscriptionInitializer.java | 55 +++ .../aggregator/service/StockPriceCache.java | 25 + .../gx/aggregator/service/TradeService.java | 25 + .../gx/aggregator/service/UserService.java | 22 + .../main/resources/application-dev.properties | 23 + .../src/main/resources/application.properties | 26 +- .../src/main/resources/static/index.html | 451 ++++++++++++++++++ .../com/gx/user/entity/PortfolioItem.java | 4 + .../main/java/com/gx/user/entity/User.java | 3 + .../repository/PortfolioItemRepository.java | 3 +- .../service/StockTradeRequestHandler.java | 5 +- .../main/resources/application-dev.properties | 2 + .../user-service/src/main/resources/data.sql | 11 +- package.json | 2 +- 22 files changed, 980 insertions(+), 10 deletions(-) create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/config/GrpcConfig.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/StockController.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/TradeController.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/UserController.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/advice/ApplicationExceptionAdviceHandler.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/dto/PriceUpdateDto.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/PriceUpdateListener.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/PriceUpdateSubscriptionInitializer.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/StockPriceCache.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/TradeService.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/UserService.java create mode 100644 apps/grpc/modules/aggregator-service/src/main/resources/application-dev.properties create mode 100644 apps/grpc/modules/aggregator-service/src/main/resources/static/index.html diff --git a/apps/grpc/modules/aggregator-service/pom.xml b/apps/grpc/modules/aggregator-service/pom.xml index 32bef5c..1383b9d 100644 --- a/apps/grpc/modules/aggregator-service/pom.xml +++ b/apps/grpc/modules/aggregator-service/pom.xml @@ -14,6 +14,25 @@ aggregator-service + + com.guan + proto-common + ${project.version} + + + + io.grpc + grpc-stub + + + io.grpc + grpc-protobuf + + + org.springframework.grpc + spring-grpc-spring-boot-starter + 1.0.1 + org.springframework.boot spring-boot-starter-web diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/config/GrpcConfig.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/config/GrpcConfig.java new file mode 100644 index 0000000..07ab550 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/config/GrpcConfig.java @@ -0,0 +1,127 @@ +package com.gx.aggregator.config; + +import java.util.logging.Logger; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.GrpcChannelFactory; + +import io.grpc.ClientInterceptor; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.stub.MetadataUtils; +import org.springframework.http.converter.protobuf.ProtobufJsonFormatHttpMessageConverter; + +import com.google.protobuf.util.JsonFormat; +import com.gx.user.UserServiceGrpc; + +/** + * Advanced gRPC channel configuration for Spring Boot 4. + * + * NOTE: Most basic settings (keep-alive, timeouts, message sizes) should be + * configured via application.properties using spring.grpc.client.* properties. + * + * Use GrpcChannelBuilderCustomizer ONLY for: + * - Custom interceptors + * - Dynamic/conditional configuration + * - Settings not available via properties + */ +@Configuration +public class GrpcConfig { + private static final Logger logger = Logger.getLogger(GrpcConfig.class.getName()); + + /** + * Global customizer applied to ALL gRPC clients. + * Use for cross-cutting concerns like logging, metrics, or auth. + */ + @Bean + public > GrpcChannelBuilderCustomizer globalChannelCustomizer() { + return (authority, builder) -> { + logger.info("Configuring global gRPC channel for: " + authority); + + // Example: Add custom interceptor for all clients + builder.intercept(loggingInterceptor()); + + // Example: Add metadata headers (e.g., for authentication) + Metadata metadata = new Metadata(); + metadata.put(Metadata.Key.of("client-id", Metadata.ASCII_STRING_MARSHALLER), "aggregator-service"); + builder.intercept(MetadataUtils.newAttachHeadersInterceptor(metadata)); + }; + } + + /** + * Client-specific customizer for user-service. + * Use for configuration unique to a specific service. + */ + @Bean + public > GrpcChannelBuilderCustomizer userServiceChannelCustomizer() { + return (authority, builder) -> { + // Only apply to user-service + if ("user-service".equals(authority)) { + logger.info("Applying user-service specific configuration"); + + // Example: Service-specific interceptor + builder.intercept(userServiceInterceptor()); + + // Example: Advanced settings not available in properties + builder.enableRetry() + .maxRetryAttempts(5) + .perRpcBufferLimit(1024 * 1024); // 1MB per RPC buffer + } + }; + } + + /** + * Example custom interceptor for logging requests/responses. + * In production, consider using observability frameworks. + */ + private ClientInterceptor loggingInterceptor() { + return new ClientInterceptor() { + @Override + public io.grpc.ClientCall interceptCall( + io.grpc.MethodDescriptor method, + io.grpc.CallOptions callOptions, + io.grpc.Channel next) { + + logger.fine(() -> "Calling method: " + method.getFullMethodName()); + return next.newCall(method, callOptions); + } + }; + } + + /** + * Example service-specific interceptor. + */ + private ClientInterceptor userServiceInterceptor() { + return new ClientInterceptor() { + @Override + public io.grpc.ClientCall interceptCall( + io.grpc.MethodDescriptor method, + io.grpc.CallOptions callOptions, + io.grpc.Channel next) { + + logger.fine(() -> "User service call: " + method.getFullMethodName()); + return next.newCall(method, callOptions); + } + }; + } + + @Bean + public ProtobufJsonFormatHttpMessageConverter protobufJsonFormatHttpMessageConverter() { + return new ProtobufJsonFormatHttpMessageConverter( + JsonFormat.parser().ignoringUnknownFields(), + JsonFormat.printer() + .omittingInsignificantWhitespace()); + } + + /** + * Create UserService gRPC stub bean. + * The authority "user-service" must match the property prefix in + * application.properties + */ + @Bean + public UserServiceGrpc.UserServiceBlockingStub userServiceBlockingStub(GrpcChannelFactory channelFactory) { + return UserServiceGrpc.newBlockingStub(channelFactory.createChannel("user-service")); + } +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/StockController.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/StockController.java new file mode 100644 index 0000000..c7b887b --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/StockController.java @@ -0,0 +1,25 @@ +package com.gx.aggregator.controller; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import com.gx.aggregator.service.PriceUpdateListener; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("stock") +@RequiredArgsConstructor +public class StockController { + + private final PriceUpdateListener priceUpdateListener; + + @GetMapping(value = "updates", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter priceUpdates() { + return this.priceUpdateListener.createEmitter(); + } + +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/TradeController.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/TradeController.java new file mode 100644 index 0000000..86c0124 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/TradeController.java @@ -0,0 +1,25 @@ +package com.gx.aggregator.controller; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.gx.aggregator.service.TradeService; +import com.gx.user.StockTradeRequest; +import com.gx.user.StockTradeResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("trade") +@RequiredArgsConstructor +public class TradeController { + private final TradeService tradeService; + + @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public StockTradeResponse trade(@RequestBody StockTradeRequest request) { + return this.tradeService.trade(request); + } +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/UserController.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/UserController.java new file mode 100644 index 0000000..5f17bd2 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/UserController.java @@ -0,0 +1,23 @@ +package com.gx.aggregator.controller; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.gx.aggregator.service.UserService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/user") +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + + @GetMapping(value = "/{userId}", produces = MediaType.APPLICATION_JSON_VALUE) + public Object getUserInformation(@PathVariable Integer userId) { + return this.userService.getUserInformation(userId); + } +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/advice/ApplicationExceptionAdviceHandler.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/advice/ApplicationExceptionAdviceHandler.java new file mode 100644 index 0000000..4580d70 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/advice/ApplicationExceptionAdviceHandler.java @@ -0,0 +1,24 @@ +package com.gx.aggregator.controller.advice; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +@ControllerAdvice +public class ApplicationExceptionAdviceHandler { + + @ExceptionHandler(StatusRuntimeException.class) + public ResponseEntity handleStatusRumtimeException(StatusRuntimeException ex) { + Status status = ex.getStatus(); + String message = status.getDescription(); + + return switch (status.getCode()) { + case INVALID_ARGUMENT, FAILED_PRECONDITION -> ResponseEntity.badRequest().body(message); + case NOT_FOUND -> ResponseEntity.notFound().build(); + case null, default -> ResponseEntity.internalServerError().body(ex.getMessage()); + }; + } +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/dto/PriceUpdateDto.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/dto/PriceUpdateDto.java new file mode 100644 index 0000000..0e9c86d --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/controller/dto/PriceUpdateDto.java @@ -0,0 +1,11 @@ +package com.gx.aggregator.controller.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PriceUpdateDto { + private String ticker; + private Integer price; +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/PriceUpdateListener.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/PriceUpdateListener.java new file mode 100644 index 0000000..e37aa39 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/PriceUpdateListener.java @@ -0,0 +1,79 @@ +package com.gx.aggregator.service; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.logging.Logger; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import com.gx.aggregator.controller.dto.PriceUpdateDto; +import com.gx.stock.PriceUpdate; + +import io.grpc.stub.StreamObserver; + +@Service +public class PriceUpdateListener implements StreamObserver { + + private static final Logger logger = Logger.getLogger(PriceUpdateListener.class.getName()); + private final Set emitters = Collections.synchronizedSet(new HashSet<>()); + private final StockPriceCache priceCache; + + @Value("${sse.timeout:300000}") + private long sseTimeout; + + public PriceUpdateListener(StockPriceCache priceCache) { + this.priceCache = priceCache; + } + + public SseEmitter createEmitter() { + var emitter = new SseEmitter(this.sseTimeout); + this.emitters.add(emitter); + // emitter.onCompletion(() -> this.emitters.remove(emitter)); + emitter.onTimeout(() -> this.emitters.remove(emitter)); + emitter.onError(e -> this.emitters.remove(emitter)); + return emitter; + } + + @Override + public void onNext(PriceUpdate value) { + var dto = PriceUpdateDto.builder() + .ticker(String.valueOf(value.getTicker())) + .price(value.getPrice()) + .build(); + // Update the price cache + this.priceCache.updatePrice(String.valueOf(value.getTicker()), value.getPrice()); + this.emitters.removeIf(emitter -> !send(emitter, dto)); + } + + @Override + public void onError(Throwable t) { + logger.severe("streaming error: " + t.getMessage()); + this.emitters.forEach(e -> e.completeWithError(t)); + this.emitters.clear(); + } + + @Override + public void onCompleted() { + // Handle stream completion + } + + public void sendPriceUpdate(PriceUpdateDto dto) { + // Update the price cache + this.priceCache.updatePrice(dto.getTicker(), dto.getPrice()); + this.emitters.removeIf(emitter -> !send(emitter, dto)); + } + + private boolean send(SseEmitter emitter, PriceUpdateDto dto) { + try { + emitter.send(dto); + return true; + } catch (Exception e) { + logger.warning("Failed to send SSE: " + e.getMessage()); + return false; + } + } + +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/PriceUpdateSubscriptionInitializer.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/PriceUpdateSubscriptionInitializer.java new file mode 100644 index 0000000..6bce84a --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/PriceUpdateSubscriptionInitializer.java @@ -0,0 +1,55 @@ +package com.gx.aggregator.service; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Service; + +import com.gx.aggregator.controller.dto.PriceUpdateDto; +import com.gx.stock.StockServiceGrpc; +import com.google.protobuf.Empty; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PriceUpdateSubscriptionInitializer implements CommandLineRunner { + private static final Logger logger = Logger.getLogger(PriceUpdateSubscriptionInitializer.class.getName()); + private final PriceUpdateListener priceUpdateListener; + private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1); + + @Override + public void run(String... args) throws Exception { + // Start mock price updates since stock-service doesn't exist yet + startMockPriceUpdates(); + } + + private void startMockPriceUpdates() { + String[] tickers = { "APPLE", "AMAZON", "GOOGLE", "MICROSOFT" }; + int[] prices = { 150, 160, 140, 180 }; + + executorService.scheduleAtFixedRate(() -> { + for (int i = 0; i < tickers.length; i++) { + // Simulate price fluctuation (ยฑ5%) + int change = (int) ((Math.random() - 0.5) * 10); + prices[i] = Math.max(100, prices[i] + change); + + PriceUpdateDto dto = PriceUpdateDto.builder() + .ticker(tickers[i]) + .price(prices[i]) + .build(); + + try { + priceUpdateListener.sendPriceUpdate(dto); + } catch (Exception e) { + logger.warning("Failed to send price update: " + e.getMessage()); + } + } + }, 0, 2, TimeUnit.SECONDS); + + logger.info("Mock price updates started"); + } +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/StockPriceCache.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/StockPriceCache.java new file mode 100644 index 0000000..5713990 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/StockPriceCache.java @@ -0,0 +1,25 @@ +package com.gx.aggregator.service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; + +import com.gx.common.Ticker; + +@Service +public class StockPriceCache { + private final Map priceCache = new ConcurrentHashMap<>(); + + public void updatePrice(String ticker, Integer price) { + priceCache.put(ticker, price); + } + + public Integer getPrice(String ticker) { + return priceCache.getOrDefault(ticker, 100); // Default price of 100 if not found + } + + public Integer getPrice(Ticker ticker) { + return getPrice(ticker.name()); + } +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/TradeService.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/TradeService.java new file mode 100644 index 0000000..5cdedae --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/TradeService.java @@ -0,0 +1,25 @@ +package com.gx.aggregator.service; + +import org.springframework.stereotype.Service; + +import com.gx.user.StockTradeRequest; +import com.gx.user.StockTradeResponse; +import com.gx.user.UserServiceGrpc; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TradeService { + private final UserServiceGrpc.UserServiceBlockingStub userServiceBlockingStub; + private final StockPriceCache priceCache; + + public StockTradeResponse trade(StockTradeRequest request) { + // Get price from cache instead of calling stock-service + var currentPrice = this.priceCache.getPrice(request.getTicker()); + var tradeRequest = request.toBuilder() + .setPrice(currentPrice) + .build(); + return this.userServiceBlockingStub.tradeStock(tradeRequest); + } +} diff --git a/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/UserService.java b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/UserService.java new file mode 100644 index 0000000..c8c41c9 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/java/com/gx/aggregator/service/UserService.java @@ -0,0 +1,22 @@ +package com.gx.aggregator.service; + +import com.gx.user.UserInformation; +import com.gx.user.UserInformationRequest; +import com.gx.user.UserServiceGrpc; + +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserServiceGrpc.UserServiceBlockingStub userServiceBlockingStub; + + public UserInformation getUserInformation(int userId) { + var request = UserInformationRequest.newBuilder() + .setUserId(userId) + .build(); + return this.userServiceBlockingStub.getUserInformation(request); + } +} diff --git a/apps/grpc/modules/aggregator-service/src/main/resources/application-dev.properties b/apps/grpc/modules/aggregator-service/src/main/resources/application-dev.properties new file mode 100644 index 0000000..e63dfa0 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/resources/application-dev.properties @@ -0,0 +1,23 @@ +# Aggregator Service - HTTP + gRPC Client +spring.application.name=AGGREGATOR-SERVICE +server.port=3001 + +# gRPC Client Configuration for user-service +# Spring gRPC (org.springframework.grpc) - named channels +spring.grpc.client.channels.user-service.address=static://localhost:9092 +spring.grpc.client.channels.user-service.negotiation-type=plaintext + +# keep-alive ่จ˜ๅพ—่ฆ enable๏ผˆไธ็„ถไฝ ไธ‹้ข้‚ฃไบ› keep-alive ๅฏ่ƒฝไธ็”Ÿๆ•ˆ๏ผ‰ +spring.grpc.client.channels.user-service.enable-keep-alive=true +spring.grpc.client.channels.user-service.keep-alive-time=30s +spring.grpc.client.channels.user-service.keep-alive-timeout=10s +spring.grpc.client.channels.user-service.keep-alive-without-calls=false + +spring.grpc.client.channels.user-service.idle-timeout=5m +spring.grpc.client.channels.user-service.max-inbound-message-size=4194304B +spring.grpc.client.channels.user-service.max-inbound-metadata-size=8192B + +# Logging +logging.level.com.gx.aggregator=INFO +logging.level.io.grpc=DEBUG +logging.pattern.console=%clr(๐ŸŸข [${spring.application.name:}]){bright_green} %clr(%d{HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}){yellow} %clr(${PID:}){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx} diff --git a/apps/grpc/modules/aggregator-service/src/main/resources/application.properties b/apps/grpc/modules/aggregator-service/src/main/resources/application.properties index 7eaf237..33ba13d 100644 --- a/apps/grpc/modules/aggregator-service/src/main/resources/application.properties +++ b/apps/grpc/modules/aggregator-service/src/main/resources/application.properties @@ -1,9 +1,27 @@ # Aggregator Service - HTTP + gRPC Client -server.port=3000 +server.port=3001 -# gRPC Client Configuration -grpc.client.user-service.address=static://localhost:9091 -grpc.client.user-service.negotiationType=PLAINTEXT +# gRPC Client Configuration for user-service +# Spring gRPC (org.springframework.grpc) - named channels +spring.grpc.client.channels.user-service.address=static://localhost:9092 +spring.grpc.client.channels.user-service.negotiation-type=plaintext + +# keep-alive ่จ˜ๅพ—่ฆ enable๏ผˆไธ็„ถไฝ ไธ‹้ข้‚ฃไบ› keep-alive ๅฏ่ƒฝไธ็”Ÿๆ•ˆ๏ผ‰ +spring.grpc.client.channels.user-service.enable-keep-alive=true +spring.grpc.client.channels.user-service.keep-alive-time=30s +spring.grpc.client.channels.user-service.keep-alive-timeout=10s +spring.grpc.client.channels.user-service.keep-alive-without-calls=false + +spring.grpc.client.channels.user-service.idle-timeout=5m +spring.grpc.client.channels.user-service.max-inbound-message-size=4194304B +spring.grpc.client.channels.user-service.max-inbound-metadata-size=8192B + +# gRPC Client Configuration for stock-service +spring.grpc.client.channels.stock-service.address=static://localhost:9091 +spring.grpc.client.channels.stock-service.negotiation-type=plaintext +spring.grpc.client.channels.stock-service.keep-alive-time=30s +spring.grpc.client.channels.stock-service.keep-alive-timeout=10s # Logging logging.level.com.gx.aggregator=INFO +logging.level.io.grpc=DEBUG diff --git a/apps/grpc/modules/aggregator-service/src/main/resources/static/index.html b/apps/grpc/modules/aggregator-service/src/main/resources/static/index.html new file mode 100644 index 0000000..c894e30 --- /dev/null +++ b/apps/grpc/modules/aggregator-service/src/main/resources/static/index.html @@ -0,0 +1,451 @@ + + + + + + + + + + + Trading Platform + + +
+

Trading Platform

+
+ +
+
+
+
+
+ APPLE +
+
+
$100
+
+
+ Buy +
+
+ Sell +
+
+
+
+
+
+
+
+ AMAZON +
+
+
$100
+
+
+ Buy +
+
+ Sell +
+
+
+
+
+ +
+
+
+ GOOGLE +
+
+
$100
+
+
+ Buy +
+
+ Sell +
+
+
+
+
+ +
+
+
+ MICROSOFT +
+
+
$100
+
+
+ Buy +
+
+ Sell +
+
+
+
+
+
+ +
+
+
+
Profile
+
+ ... +
.
+
+
+ Available Balance +
+
+ $0 +
+
+
+
+ Portfolio Value +
+
+ $0 +
+
+
+
+
+ +
+
+
Portfolio
+
+ + + + + + + + + + + + +
#StockQuantityCurrent Value
+
+
+
+
+ + +
+
+ + + + +
+ + + + + + + + + diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/PortfolioItem.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/PortfolioItem.java index 658e2c2..c6c237f 100644 --- a/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/PortfolioItem.java +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/PortfolioItem.java @@ -4,12 +4,16 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Entity @Data @Builder +@AllArgsConstructor +@NoArgsConstructor public class PortfolioItem { @Id @GeneratedValue diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/User.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/User.java index 1213f96..74cd9e1 100644 --- a/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/User.java +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/entity/User.java @@ -1,6 +1,8 @@ package com.gx.user.entity; import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Data; @@ -10,6 +12,7 @@ @Data public class User { @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String name; private Integer balance; diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/PortfolioItemRepository.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/PortfolioItemRepository.java index f6ccaa1..7145886 100644 --- a/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/PortfolioItemRepository.java +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/repository/PortfolioItemRepository.java @@ -7,11 +7,10 @@ import org.springframework.stereotype.Repository; import com.gx.user.entity.PortfolioItem; -import com.gx.common.Ticker; @Repository public interface PortfolioItemRepository extends CrudRepository { List findAllByUserId(Integer userId); - Optional findByUserIdAndTicker(Integer userId, Ticker ticker); + Optional findByUserIdAndTicker(Integer userId, String ticker); } diff --git a/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/StockTradeRequestHandler.java b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/StockTradeRequestHandler.java index 59cb9f3..9a525a1 100644 --- a/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/StockTradeRequestHandler.java +++ b/apps/grpc/modules/user-service/src/main/java/com/gx/user/service/StockTradeRequestHandler.java @@ -32,7 +32,7 @@ public StockTradeResponse buyStock(StockTradeRequest request) { user.setBalance(user.getBalance() - totalPrice); - this.portfolioItemRepository.findByUserIdAndTicker(request.getUserId(), request.getTicker()) + this.portfolioItemRepository.findByUserIdAndTicker(request.getUserId(), request.getTicker().name()) .ifPresentOrElse(item -> item.setQuantity(item.getQuantity() + request.getQuantity()), () -> { var newItem = this.entityMapper.toPortfolioItem(request); this.portfolioItemRepository.save(newItem); @@ -46,7 +46,8 @@ public StockTradeResponse sellStock(StockTradeRequest request) { var user = this.userRepository.findById(request.getUserId()) .orElseThrow(() -> new UnkownUserException(request.getUserId())); - var portfolioItem = this.portfolioItemRepository.findByUserIdAndTicker(request.getUserId(), request.getTicker()) + var portfolioItem = this.portfolioItemRepository + .findByUserIdAndTicker(request.getUserId(), request.getTicker().name()) .filter(item -> item.getQuantity() >= request.getQuantity()) .orElseThrow(() -> new InsufficientBalanceException(user.getId())); diff --git a/apps/grpc/modules/user-service/src/main/resources/application-dev.properties b/apps/grpc/modules/user-service/src/main/resources/application-dev.properties index 696ab7f..6672d98 100644 --- a/apps/grpc/modules/user-service/src/main/resources/application-dev.properties +++ b/apps/grpc/modules/user-service/src/main/resources/application-dev.properties @@ -1,4 +1,5 @@ # User Service - Dev Profile +spring.application.name=USER-SERVICE server.port=9090 spring.grpc.server.port=9092 @@ -12,6 +13,7 @@ spring.h2.console.path=/h2-console # Logging logging.level.com.gx.user=DEBUG +logging.pattern.console=%clr(๐Ÿ”ต [${spring.application.name:}]){bright_blue} %clr(%d{HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:%5p}){yellow} %clr(${PID:}){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:%wEx} # SQL Initialization spring.sql.init.mode=always diff --git a/apps/grpc/modules/user-service/src/main/resources/data.sql b/apps/grpc/modules/user-service/src/main/resources/data.sql index 1e808e9..e1eff28 100644 --- a/apps/grpc/modules/user-service/src/main/resources/data.sql +++ b/apps/grpc/modules/user-service/src/main/resources/data.sql @@ -20,4 +20,13 @@ insert into customer(name, balance) values ('Sam', 10000), ('Mike', 10000), - ('John', 10000); \ No newline at end of file + ('John', 10000); + +insert into portfolio_item(customer_id, ticker, quantity) + values + (1, 'APPLE', 5), + (1, 'GOOGLE', 10), + (2, 'AMAZON', 8), + (2, 'MICROSOFT', 3), + (3, 'GOOGLE', 15), + (3, 'APPLE', 7); \ No newline at end of file diff --git a/package.json b/package.json index 50af46d..5fa53f3 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "grpc:serve": "nx run grpc:serve", "grpc:serve:user": "nx run grpc:serve:user", "grpc:serve:aggregator": "nx run grpc:serve:aggregator", - "grpc:build": "nx run grpc:build", + "grpc:build": "cross-env \"nx run grpc:clean && nx run grpc:build\"", "grpc:test": "nx run grpc:test", "grpc:clean": "nx run grpc:clean" }, From 2ce4cfe08a816ca1c9df5f248fe3507b045b6539 Mon Sep 17 00:00:00 2001 From: "MSI\\guanxin" Date: Sat, 31 Jan 2026 08:38:54 +0800 Subject: [PATCH 3/6] fix: remove main-app and add readme --- apps/grpc/README.md | 237 ++++++++++++++++++ apps/grpc/modules/main-app/pom.xml | 75 ------ .../java/com/gx/grpc/GrpcApplication.java | 30 --- .../main/resources/application-dev.properties | 16 -- .../src/main/resources/application.properties | 16 -- 5 files changed, 237 insertions(+), 137 deletions(-) create mode 100644 apps/grpc/README.md delete mode 100644 apps/grpc/modules/main-app/pom.xml delete mode 100644 apps/grpc/modules/main-app/src/main/java/com/gx/grpc/GrpcApplication.java delete mode 100644 apps/grpc/modules/main-app/src/main/resources/application-dev.properties delete mode 100644 apps/grpc/modules/main-app/src/main/resources/application.properties diff --git a/apps/grpc/README.md b/apps/grpc/README.md new file mode 100644 index 0000000..4f1d13a --- /dev/null +++ b/apps/grpc/README.md @@ -0,0 +1,237 @@ +# gRPC Application + +A Spring Boot-based microservices application demonstrating gRPC communication patterns between services, including both service-to-service gRPC calls and HTTP REST endpoints for external access. + +## ๐Ÿ“‹ Overview + +This application consists of multiple microservices that communicate via gRPC: + +- **proto-common**: Shared protobuf definitions for service contracts +- **user-service**: Backend service providing user management via gRPC (port 9092) +- **aggregator-service**: API gateway that exposes REST endpoints and communicates with backend services via gRPC clients (port 3001) + +## ๐Ÿ—๏ธ Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Client/Frontend โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ HTTP/REST + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Aggregator Service โ”‚ (Port 3001) +โ”‚ - REST API โ”‚ +โ”‚ - gRPC Client โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ gRPC + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ User Service โ”‚ (Port 9092 - gRPC) +โ”‚ - gRPC Server โ”‚ (Port 9090 - HTTP) +โ”‚ - H2 Database โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## ๐Ÿš€ Quick Start + +### Prerequisites + +- Java 17 or higher +- Maven 3.9+ (or use the included `build-and-run.sh` script) +- Port 3001, 9090, and 9092 available + +### Using Nx (Recommended) + +The project is integrated with Nx for efficient task execution: + +```bash +# Build all modules +nx run grpc:build + +# Run all services in parallel (dev profile) +nx run grpc:serve + +# Run individual services +nx run grpc:serve:user # User service only +nx run grpc:serve:aggregator # Aggregator service only + +# Run tests +nx run grpc:test + +# Clean build artifacts +nx run grpc:clean +``` + +### Using Maven Directly + +```bash +# From apps/grpc directory + +# Build all modules +mvn clean install -DskipTests + +# Run user-service +mvn spring-boot:run -pl modules/user-service -Dspring-boot.run.profiles=dev + +# Run aggregator-service (in separate terminal) +mvn spring-boot:run -pl modules/aggregator-service -Dspring-boot.run.profiles=dev +``` + +### Using the Build Script + +```bash +# Auto-downloads Maven if needed and builds the project +./build-and-run.sh +``` + +## ๐Ÿ“ฆ Modules + +### proto-common + +Shared protobuf definitions that define the service contracts. + +**Location**: `modules/proto-common/src/main/proto/` + +- `user-service.proto` - User service RPC definitions +- `stock-service.proto` - Stock service RPC definitions +- `common/` - Shared message types + +The protobuf files are compiled to Java classes using the `protobuf-maven-plugin`. + +### user-service + +Backend microservice providing user management functionality via gRPC. + +**Ports**: +- `9090` - HTTP (Health checks, H2 console) +- `9092` - gRPC Server + +**Features**: +- gRPC service implementation +- Spring Data JPA with H2 database +- SQL initialization on startup +- H2 console available at: http://localhost:9090/h2-console + +**Database Configuration** (dev): +- URL: `jdbc:h2:mem:userdb` +- Username: `sa` +- Password: *(empty)* + +### aggregator-service + +API Gateway that exposes REST endpoints and aggregates data from backend services using gRPC clients. + +**Port**: `3001` (HTTP/REST) + +**Features**: +- REST API endpoints for external clients +- gRPC client to communicate with user-service +- Protobuf-JSON conversion for REST responses +- Keep-alive and connection management for gRPC channels + +**gRPC Client Configuration**: +- Target: `localhost:9092` (user-service) +- Keep-alive: 30s +- Idle timeout: 5m +- Max inbound message size: 4MB + +## ๐Ÿ”ง Configuration Profiles + +The application supports multiple Spring profiles: + +- `dev` - Development profile (default) +- `development` - Extended development configuration +- `production` - Production configuration + +Switch profiles using: +```bash +-Dspring-boot.run.profiles=dev +``` + +## ๐Ÿงช Testing + +```bash +# Run all tests +nx run grpc:test + +# Or with Maven +mvn test +``` + +## ๐Ÿ› ๏ธ Development + +### Adding New Services + +1. Create a new module in `modules/` +2. Define service contract in `proto-common/src/main/proto/` +3. Update parent `pom.xml` to include the new module +4. Implement gRPC service or client +5. Update `project.json` for Nx integration + +### Protobuf Changes + +After modifying `.proto` files: + +```bash +# Rebuild proto-common to regenerate Java classes +mvn clean install -pl modules/proto-common + +# Then rebuild dependent services +mvn clean install -pl modules/user-service,modules/aggregator-service +``` + +## ๐Ÿ“Š Monitoring & Debugging + +### H2 Database Console + +Access the user-service database: +- URL: http://localhost:9090/h2-console +- JDBC URL: `jdbc:h2:mem:userdb` +- Username: `sa` +- Password: *(empty)* + +### Health Checks + +- User Service: http://localhost:9090/actuator/health +- Aggregator Service: http://localhost:3001/actuator/health + +### Logging + +Both services use colored console logging with service name prefixes: +- ๐Ÿ”ต USER-SERVICE +- ๐ŸŸข AGGREGATOR-SERVICE + +Log levels can be adjusted in `application-dev.properties`. + +## ๐Ÿ” Dependencies + +Key technologies used: + +- **Spring Boot** - Application framework +- **Spring gRPC** (1.0.1) - gRPC integration for Spring +- **gRPC** - High-performance RPC framework +- **Protocol Buffers** - Serialization format +- **Spring Data JPA** - Database access +- **H2 Database** - In-memory database +- **Lombok** - Boilerplate reduction + +## ๐Ÿ“ Notes + +- All services must be running for the aggregator to work properly +- The aggregator service depends on user-service being available at `localhost:9092` +- gRPC communication uses plaintext (no TLS) in development mode +- The build includes automatic protobuf compilation via Maven plugins + +## ๐Ÿค Contributing + +When making changes: + +1. Follow the existing code structure +2. Update proto definitions in `proto-common` for API changes +3. Maintain backward compatibility when possible +4. Add tests for new functionality +5. Update this README if adding new services or changing configuration + +## ๐Ÿ“„ License + +Part of the gx.java project. diff --git a/apps/grpc/modules/main-app/pom.xml b/apps/grpc/modules/main-app/pom.xml deleted file mode 100644 index 0a51154..0000000 --- a/apps/grpc/modules/main-app/pom.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - 4.0.0 - - com.guan - grpc-app - 0.0.1-SNAPSHOT - ../../pom.xml - - - main-app - jar - Main Application - - - - - com.guan - proto-common - ${project.version} - - - com.guan - user-service - ${project.version} - - - com.guan - aggregator-service - ${project.version} - - - - - org.springframework.boot - spring-boot-starter-web - - - - - com.h2database - h2 - runtime - - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - com.gx.grpc.GrpcApplication - false - - - - - repackage - - - - - - - - diff --git a/apps/grpc/modules/main-app/src/main/java/com/gx/grpc/GrpcApplication.java b/apps/grpc/modules/main-app/src/main/java/com/gx/grpc/GrpcApplication.java deleted file mode 100644 index c482dac..0000000 --- a/apps/grpc/modules/main-app/src/main/java/com/gx/grpc/GrpcApplication.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.gx.grpc; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.core.env.Environment; -import org.springframework.beans.factory.annotation.Autowired; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@SpringBootApplication -public class GrpcApplication { - - private static final Logger logger = LoggerFactory.getLogger(GrpcApplication.class); - - @Autowired - private Environment environment; - - public static void main(String[] args) { - SpringApplication.run(GrpcApplication.class, args); - } - - @EventListener(ApplicationReadyEvent.class) - public void onApplicationReady() { - String port = environment.getProperty("server.port", "8080"); - logger.info("Spring Backend Application is running on port: {}", port); - logger.info("Application URL: http://localhost:{}", port); - } -} diff --git a/apps/grpc/modules/main-app/src/main/resources/application-dev.properties b/apps/grpc/modules/main-app/src/main/resources/application-dev.properties deleted file mode 100644 index 4283f00..0000000 --- a/apps/grpc/modules/main-app/src/main/resources/application-dev.properties +++ /dev/null @@ -1,16 +0,0 @@ - -server.port=3000 - -# Default datasource (overridden by DataSourceConfig if repo sqlite exists) -spring.datasource.url=jdbc:h2:mem:springdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.username=sa -spring.datasource.password= -spring.datasource.driver-class-name=org.h2.Driver - -# JPA default behavior -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=false -spring.jpa.properties.hibernate.format_sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console diff --git a/apps/grpc/modules/main-app/src/main/resources/application.properties b/apps/grpc/modules/main-app/src/main/resources/application.properties deleted file mode 100644 index 4283f00..0000000 --- a/apps/grpc/modules/main-app/src/main/resources/application.properties +++ /dev/null @@ -1,16 +0,0 @@ - -server.port=3000 - -# Default datasource (overridden by DataSourceConfig if repo sqlite exists) -spring.datasource.url=jdbc:h2:mem:springdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.username=sa -spring.datasource.password= -spring.datasource.driver-class-name=org.h2.Driver - -# JPA default behavior -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=false -spring.jpa.properties.hibernate.format_sql=true -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console From 1cd082cadc446be24a4d0d1539a67673c4c5675a Mon Sep 17 00:00:00 2001 From: "MSI\\guanxin" Date: Mon, 2 Feb 2026 09:46:04 +0800 Subject: [PATCH 4/6] feat: java basics project migration --- apps/grpc/build-and-run.sh | 43 ------ apps/springbootup/.gitignore | 33 +++++ apps/springbootup/pom.xml | 139 ++++++++++++++++++ apps/springbootup/project.json | 46 ++++++ .../example/demo/SpringbootupApplication.java | 17 +++ .../example/demo/aspect/CommonPointcuts.java | 31 ++++ .../demo/aspect/GlobalAuditAspect.java | 42 ++++++ .../demo/aspect/GlobalExceptionAspect.java | 29 ++++ .../demo/aspect/GlobalMonitoringAspect.java | 46 ++++++ .../example/demo/common/EntityDtoMapper.java | 30 ++++ .../demo/common/GlobalExceptionHandler.java | 15 ++ .../com/example/demo/config/AopConfig.java | 9 ++ .../example/demo/config/DatabaseConfig.java | 15 ++ .../demo/config/ModelMapperConfig.java | 20 +++ .../example/demo/config/OpenAPIConfig.java | 32 ++++ .../controllers/DepartmentController.java | 86 +++++++++++ .../demo/controllers/EmployeeController.java | 71 +++++++++ .../controllers/HealthController.java.bak | 23 +++ .../com/example/demo/dao/MembershipDAO.java | 7 + .../example/demo/dao/MembershipDAOImpl.java | 18 +++ .../main/java/com/example/demo/dao/README.md | 45 ++++++ .../com/example/demo/dto/DepartmentDto.java | 12 ++ .../com/example/demo/dto/EmployeeDto.java | 12 ++ .../com/example/demo/entity/Department.java | 56 +++++++ .../com/example/demo/entity/Employee.java | 41 ++++++ .../health/CustomHealthIndicator.java.bak | 26 ++++ .../repositories/DepartmentRepository.java | 11 ++ .../demo/repositories/EmployeeRepository.java | 10 ++ .../department/DepartmentService.java | 58 ++++++++ .../services/employee/EmployeeService.java | 54 +++++++ .../trafficfortune/TrafficFortuneService.java | 7 + .../TrafficFortuneServiceImpl.java | 30 ++++ .../src/main/resources/application.properties | 36 +++++ apps/springbootup/src/main/resources/data.sql | 4 + .../src/main/resources/schema.sql | 29 ++++ .../demo/SpringbootupApplicationTests.java | 13 ++ package.json | 6 +- pom.xml | 1 + 38 files changed, 1159 insertions(+), 44 deletions(-) delete mode 100644 apps/grpc/build-and-run.sh create mode 100644 apps/springbootup/.gitignore create mode 100644 apps/springbootup/pom.xml create mode 100644 apps/springbootup/project.json create mode 100644 apps/springbootup/src/main/java/com/example/demo/SpringbootupApplication.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/aspect/CommonPointcuts.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/aspect/GlobalAuditAspect.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/aspect/GlobalExceptionAspect.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/aspect/GlobalMonitoringAspect.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/common/EntityDtoMapper.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/common/GlobalExceptionHandler.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/config/AopConfig.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/config/DatabaseConfig.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/config/ModelMapperConfig.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/config/OpenAPIConfig.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/controllers/DepartmentController.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/controllers/EmployeeController.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/controllers/HealthController.java.bak create mode 100644 apps/springbootup/src/main/java/com/example/demo/dao/MembershipDAO.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/dao/MembershipDAOImpl.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/dao/README.md create mode 100644 apps/springbootup/src/main/java/com/example/demo/dto/DepartmentDto.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/dto/EmployeeDto.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/entity/Department.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/entity/Employee.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/health/CustomHealthIndicator.java.bak create mode 100644 apps/springbootup/src/main/java/com/example/demo/repositories/DepartmentRepository.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/repositories/EmployeeRepository.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/services/department/DepartmentService.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/services/employee/EmployeeService.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/services/trafficfortune/TrafficFortuneService.java create mode 100644 apps/springbootup/src/main/java/com/example/demo/services/trafficfortune/TrafficFortuneServiceImpl.java create mode 100644 apps/springbootup/src/main/resources/application.properties create mode 100644 apps/springbootup/src/main/resources/data.sql create mode 100644 apps/springbootup/src/main/resources/schema.sql create mode 100644 apps/springbootup/src/test/java/com/example/demo/SpringbootupApplicationTests.java diff --git a/apps/grpc/build-and-run.sh b/apps/grpc/build-and-run.sh deleted file mode 100644 index fa2208e..0000000 --- a/apps/grpc/build-and-run.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Simple helper to download a local Maven and build the grpc module -ROOT_DIR=$(cd "$(dirname "$0")" && pwd) -cd "$ROOT_DIR" - -MAVEN_VERSION="3.9.12" -MAVEN_DIR="$ROOT_DIR/.maven/apache-maven-$MAVEN_VERSION" -MAVEN_BIN="$MAVEN_DIR/bin/mvn" - -if [ ! -x "$MAVEN_BIN" ]; then - echo "Maven not found locally. Downloading Apache Maven $MAVEN_VERSION..." - ARCHIVE="apache-maven-$MAVEN_VERSION-bin.tar.gz" - URL="https://archive.apache.org/dist/maven/maven-3/$MAVEN_VERSION/binaries/$ARCHIVE" - - tmpfile="$ROOT_DIR/$ARCHIVE" - if command -v curl >/dev/null 2>&1; then - curl -L -o "$tmpfile" "$URL" - elif command -v wget >/dev/null 2>&1; then - wget -O "$tmpfile" "$URL" - else - echo "Error: neither curl nor wget is available to download Maven. Please install one or install Maven manually." >&2 - exit 2 - fi - - mkdir -p "$ROOT_DIR/.maven" - tar -xzf "$tmpfile" -C "$ROOT_DIR/.maven" - rm -f "$tmpfile" - echo "Maven downloaded to $MAVEN_DIR" -fi - -echo "Building grpc with local Maven..." -"$MAVEN_BIN" -f "$ROOT_DIR/pom.xml" -DskipTests package - -JAR="$ROOT_DIR/target/grpc-0.0.1-SNAPSHOT.jar" -if [ -f "$JAR" ]; then - echo "Build succeeded. Jar: $JAR" - echo "To run: java -jar $JAR" -else - echo "Build finished but jar not found. Check Maven output for errors." >&2 - exit 3 -fi diff --git a/apps/springbootup/.gitignore b/apps/springbootup/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/apps/springbootup/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/apps/springbootup/pom.xml b/apps/springbootup/pom.xml new file mode 100644 index 0000000..2b10368 --- /dev/null +++ b/apps/springbootup/pom.xml @@ -0,0 +1,139 @@ + + + 4.0.0 + + com.guan + grpc-practice + 0.0.1-SNAPSHOT + ../../pom.xml + + com.springboot.collection + demo + 0.0.1-SNAPSHOT + springbootup + Demo project for Spring Boot + + + + + + + + + + + + + + + + + org.springframework.boot + spring-boot-dependencies + 4.0.2 + pom + import + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + 4.0.2 + + + + org.springframework + spring-aop + + + + org.aspectj + aspectjweaver + + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-web + 4.0.2 + + + + org.springframework.boot + spring-boot-starter-actuator + 4.0.2 + + + + org.springframework.boot + spring-boot-starter-test + 4.0.2 + test + + + org.projectlombok + lombok + true + + + + net.logstash.logback + logstash-logback-encoder + 4.9 + + + org.springframework.boot + spring-boot-starter-validation + 4.0.2 + + + org.modelmapper + modelmapper + 3.2.0 + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/apps/springbootup/project.json b/apps/springbootup/project.json new file mode 100644 index 0000000..74269fe --- /dev/null +++ b/apps/springbootup/project.json @@ -0,0 +1,46 @@ +{ + "name": "springbootup", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/springbootup/src", + "targets": { + "serve": { + "executor": "nx:run-commands", + "options": { + "command": "mvn spring-boot:run -Dspring-boot.run.profiles=dev -DskipTests", + "cwd": "{workspaceRoot}/apps/springbootup" + }, + "configurations": { + "development": { + "command": "mvn spring-boot:run -Dspring-boot.run.profiles=development -DskipTests" + }, + "production": { + "command": "mvn spring-boot:run -Dspring-boot.run.profiles=production -DskipTests" + } + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "command": "mvn clean install -DskipTests", + "cwd": "{workspaceRoot}/apps/springbootup" + }, + "outputs": ["{projectRoot}/target"] + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "mvn test", + "cwd": "{workspaceRoot}/apps/springbootup" + } + }, + "clean": { + "executor": "nx:run-commands", + "options": { + "command": "mvn clean", + "cwd": "{workspaceRoot}/apps/springbootup" + } + } + }, + "tags": ["backend", "java", "spring", "springboot"] +} diff --git a/apps/springbootup/src/main/java/com/example/demo/SpringbootupApplication.java b/apps/springbootup/src/main/java/com/example/demo/SpringbootupApplication.java new file mode 100644 index 0000000..c56f9cf --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/SpringbootupApplication.java @@ -0,0 +1,17 @@ +package com.example.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import lombok.extern.slf4j.Slf4j; + +@SpringBootApplication +@Slf4j +@EnableJpaAuditing +public class SpringbootupApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringbootupApplication.class, args); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/aspect/CommonPointcuts.java b/apps/springbootup/src/main/java/com/example/demo/aspect/CommonPointcuts.java new file mode 100644 index 0000000..88f0949 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/aspect/CommonPointcuts.java @@ -0,0 +1,31 @@ +package com.example.demo.aspect; + +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.stereotype.Component; + +@Component +public class CommonPointcuts { + + // Service layer pointcuts + @Pointcut("execution(* com.example.demo.services..*.*(..))") + public void serviceLayer() { + } + + // Repository layer pointcuts + @Pointcut("execution(* com.example.demo.repositories..*.*(..))") + public void repositoryLayer() { + } + + // Write operations pointcuts + @Pointcut("execution(* com.example.demo.services..*.save*(..)) || " + + "execution(* com.example.demo.services..*.update*(..)) || " + + "execution(* com.example.demo.services..*.delete*(..))") + public void writeOperations() { + } + + // Read operations pointcuts + @Pointcut("execution(* com.example.demo.services..*.get*(..)) || " + + "execution(* com.example.demo.services..*.find*(..))") + public void readOperations() { + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/aspect/GlobalAuditAspect.java b/apps/springbootup/src/main/java/com/example/demo/aspect/GlobalAuditAspect.java new file mode 100644 index 0000000..0ad9a74 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/aspect/GlobalAuditAspect.java @@ -0,0 +1,42 @@ +package com.example.demo.aspect; + +import java.util.Arrays; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Aspect +@Component +@Order(3) +@Slf4j +public class GlobalAuditAspect { + + private static final String YELLOW = "\u001B[33m"; + private static final String BLUE = "\u001B[34m"; + private static final String RESET = "\u001B[0m"; + + @Before("com.example.demo.aspects.CommonPointcuts.writeOperations()") + public void logWriteOperation(JoinPoint joinPoint) { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + String args = Arrays.toString(joinPoint.getArgs()); + + log.info("{}โšก Write operation: {}.{} with data: {}{}", + YELLOW, className, methodName, args, RESET); + } + + @AfterReturning(pointcut = "com.example.demo.aspects.CommonPointcuts.readOperations()", returning = "result") + public void logReadOperation(JoinPoint joinPoint, Object result) { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + log.info("{}๐Ÿ“– Read operation: {}.{} completed{}", + BLUE, className, methodName, RESET); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/aspect/GlobalExceptionAspect.java b/apps/springbootup/src/main/java/com/example/demo/aspect/GlobalExceptionAspect.java new file mode 100644 index 0000000..aad1046 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/aspect/GlobalExceptionAspect.java @@ -0,0 +1,29 @@ +package com.example.demo.aspect; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Aspect +@Component +@Order(2) +@Slf4j +public class GlobalExceptionAspect { + + private static final String RED = "\u001B[31m"; + private static final String RESET = "\u001B[0m"; + + @AfterThrowing(pointcut = "com.example.demo.aspects.CommonPointcuts.serviceLayer()", throwing = "error") + public void logException(JoinPoint joinPoint, Throwable error) { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + log.error("{}โŒ Exception in {}.{}: {} - {}{}", + RED, className, methodName, + error.getClass().getSimpleName(), + error.getMessage(), RESET); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/aspect/GlobalMonitoringAspect.java b/apps/springbootup/src/main/java/com/example/demo/aspect/GlobalMonitoringAspect.java new file mode 100644 index 0000000..b675cc5 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/aspect/GlobalMonitoringAspect.java @@ -0,0 +1,46 @@ +package com.example.demo.aspect; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Aspect +@Component +@Order(1) // Highest priority +@Slf4j +public class GlobalMonitoringAspect { + + private static final String CYAN = "\u001B[36m"; + private static final String GREEN = "\u001B[32m"; + private static final String RED = "\u001B[31m"; + private static final String RESET = "\u001B[0m"; + + @Around("com.example.demo.aspects.CommonPointcuts.serviceLayer()") + public Object monitorServicePerformance(ProceedingJoinPoint joinPoint) throws Throwable { + long startTime = System.currentTimeMillis(); + String methodName = joinPoint.getSignature().getName(); + String className = joinPoint.getTarget().getClass().getSimpleName(); + + try { + log.info("{}โ‡’ Executing {}.{}(){}", + CYAN, className, methodName, RESET); + + Object result = joinPoint.proceed(); + + long duration = System.currentTimeMillis() - startTime; + log.info("{}โœ“ {}.{} completed in {}ms{}", + GREEN, className, methodName, duration, RESET); + + return result; + } catch (Throwable e) { + log.error("{}โœ— {}.{} failed in {}ms{}", + RED, className, methodName, + (System.currentTimeMillis() - startTime), RESET); + throw e; + } + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/common/EntityDtoMapper.java b/apps/springbootup/src/main/java/com/example/demo/common/EntityDtoMapper.java new file mode 100644 index 0000000..ccc08b1 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/common/EntityDtoMapper.java @@ -0,0 +1,30 @@ +package com.example.demo.common; + +import org.modelmapper.Conditions; +import org.modelmapper.ModelMapper; +import org.modelmapper.convention.MatchingStrategies; +import org.springframework.stereotype.Component; + +@Component +public class EntityDtoMapper { + private final ModelMapper modelMapper; + + public EntityDtoMapper() { + this.modelMapper = new ModelMapper(); + configureMapper(); + } + + private void configureMapper() { + modelMapper.getConfiguration() + .setMatchingStrategy(MatchingStrategies.STRICT) + .setPropertyCondition(Conditions.isNotNull()); + } + + public D convertToDto(T entity, Class dtoClass) { + return modelMapper.map(entity, dtoClass); + } + + public T convertToEntity(D dto, Class entityClass) { + return modelMapper.map(dto, entityClass); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/common/GlobalExceptionHandler.java b/apps/springbootup/src/main/java/com/example/demo/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..566d434 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/common/GlobalExceptionHandler.java @@ -0,0 +1,15 @@ +package com.example.demo.common; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import jakarta.persistence.EntityNotFoundException; + +@ControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { + return ResponseEntity.notFound().build(); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/config/AopConfig.java b/apps/springbootup/src/main/java/com/example/demo/config/AopConfig.java new file mode 100644 index 0000000..45c81ac --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/config/AopConfig.java @@ -0,0 +1,9 @@ +package com.example.demo.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + +@Configuration +@EnableAspectJAutoProxy +public class AopConfig { +} diff --git a/apps/springbootup/src/main/java/com/example/demo/config/DatabaseConfig.java b/apps/springbootup/src/main/java/com/example/demo/config/DatabaseConfig.java new file mode 100644 index 0000000..73ad2af --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/config/DatabaseConfig.java @@ -0,0 +1,15 @@ +package com.example.demo.config; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class DatabaseConfig { + @Bean + public JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/config/ModelMapperConfig.java b/apps/springbootup/src/main/java/com/example/demo/config/ModelMapperConfig.java new file mode 100644 index 0000000..7639edc --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/config/ModelMapperConfig.java @@ -0,0 +1,20 @@ +package com.example.demo.config; + +import org.modelmapper.ModelMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * The goal of ModelMapper is to make object mapping easy, by automatically + * determining how one object model maps to another, based on conventions, in + * the same way that a human would - while providing a simple, refactoring-safe + * API for handling specific use cases. + * {@link https://modelmapper.org/} + */ +@Configuration +public class ModelMapperConfig { + @Bean + public ModelMapper modelMapper() { + return new ModelMapper(); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/config/OpenAPIConfig.java b/apps/springbootup/src/main/java/com/example/demo/config/OpenAPIConfig.java new file mode 100644 index 0000000..ff56493 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/config/OpenAPIConfig.java @@ -0,0 +1,32 @@ +package com.example.demo.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class OpenAPIConfig { + @Bean + public OpenAPI defineOpenApi() { + Server server = new Server(); + server.setUrl("http://localhost:8080"); + server.setDescription("Development"); + + Contact contact = new Contact(); + contact.setName("Your Name"); + contact.setEmail("your.email@example.com"); + + Info information = new Info() + .title("Your API Title") + .version("1.0") + .description("Your API Description") + .contact(contact); + + return new OpenAPI().info(information).servers(List.of(server)); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/controllers/DepartmentController.java b/apps/springbootup/src/main/java/com/example/demo/controllers/DepartmentController.java new file mode 100644 index 0000000..743cd86 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/controllers/DepartmentController.java @@ -0,0 +1,86 @@ +package com.example.demo.controllers; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.demo.dto.DepartmentDto; +import com.example.demo.entity.Department; +import com.example.demo.services.department.DepartmentService; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping(path = "/departments", produces = MediaType.APPLICATION_JSON_VALUE) +public class DepartmentController { + + private DepartmentService departmentService; + + public DepartmentController(DepartmentService theDepartmentService) { + departmentService = theDepartmentService; + } + + @GetMapping("/list") + public List listDepartments() { + return departmentService.getAllDepartments(); + } + + @GetMapping("/get/{id}") + public ResponseEntity getDepartment(@PathVariable("id") int id) { + DepartmentDto department = departmentService.getDepartment(id); + return ResponseEntity.ok(department); + } + + @PostMapping("/save") + public ResponseEntity saveDepartment(@Valid @RequestBody Department theDepartment) { + if (theDepartment.getDepartmentCode().length() > 10) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(null); + } + + Department savedDepartment = departmentService.save(theDepartment); + return ResponseEntity.ok(savedDepartment); + } + + @DeleteMapping("/delete/{id}") + public ResponseEntity delete(@PathVariable("id") int id) { + Department department = departmentService.findById(id); + + // Check if department exists before attempting to delete + if (department == null) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body("Department with ID " + id + " not found"); + } + + // Perform deletion + departmentService.deleteById(id); + + // Verify deletion + if (departmentService.findById(id) == null) { + return ResponseEntity + .ok() + .body("Department with ID " + id + " deleted successfully"); + } else { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to delete department with ID " + id); + } + } + + @DeleteMapping("/deleteAll") + public ResponseEntity deleteAll() { + departmentService.deleteAll(); + return ResponseEntity.ok("All departments deleted successfully"); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/controllers/EmployeeController.java b/apps/springbootup/src/main/java/com/example/demo/controllers/EmployeeController.java new file mode 100644 index 0000000..6f55554 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/controllers/EmployeeController.java @@ -0,0 +1,71 @@ +package com.example.demo.controllers; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.demo.dto.EmployeeDto; +import com.example.demo.entity.Employee; +import com.example.demo.services.employee.EmployeeService; + +@RestController +@RequestMapping(path = "/employees", produces = MediaType.APPLICATION_JSON_VALUE) +public class EmployeeController { + + private EmployeeService employeeService; + + public EmployeeController(EmployeeService theEmployeeService) { + employeeService = theEmployeeService; + } + + @GetMapping("/list") + public List listEmployees() { + return employeeService.findAll(); + } + + @GetMapping("/get/{id}") + public ResponseEntity getEmployee(@PathVariable("id") int theId) { + EmployeeDto savedEmployee = employeeService.getEmployee(theId); + return ResponseEntity.ok(savedEmployee); + } + + @PostMapping("/save") + public ResponseEntity saveEmployee(@RequestBody Employee theEmployee) { + Employee savedEmployee = employeeService.save(theEmployee); + return ResponseEntity.ok(savedEmployee); + } + + @GetMapping("/delete/{id}") + public ResponseEntity delete(@PathVariable("id") int theId) { + Employee employee = employeeService.findById(theId); + + // Check if employee exists before attempting to delete + if (employee == null) { + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body("Employee with ID " + theId + " not found"); + } + + // Perform deletion + employeeService.deleteById(theId); + + // Verify deletion + if (employeeService.findById(theId) == null) { + return ResponseEntity + .ok() + .body("Employee with ID " + theId + " deleted successfully"); + } else { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to delete employee with ID " + theId); + } + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/controllers/HealthController.java.bak b/apps/springbootup/src/main/java/com/example/demo/controllers/HealthController.java.bak new file mode 100644 index 0000000..f634674 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/controllers/HealthController.java.bak @@ -0,0 +1,23 @@ +package com.example.demo.controllers; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; + +@RestController +public class HealthController { + + private final HealthIndicator customHealthIndicator; + + public HealthController(HealthIndicator customHealthIndicator) { + this.customHealthIndicator = customHealthIndicator; + } + + @GetMapping("/health") + public ResponseEntity healthCheck() { + Health health = customHealthIndicator.health(); + return ResponseEntity.ok(health); + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/dao/MembershipDAO.java b/apps/springbootup/src/main/java/com/example/demo/dao/MembershipDAO.java new file mode 100644 index 0000000..b127a79 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/dao/MembershipDAO.java @@ -0,0 +1,7 @@ +package com.example.demo.dao; + +public interface MembershipDAO { + boolean addSillyMember(); + + void goToSleep(); +} diff --git a/apps/springbootup/src/main/java/com/example/demo/dao/MembershipDAOImpl.java b/apps/springbootup/src/main/java/com/example/demo/dao/MembershipDAOImpl.java new file mode 100644 index 0000000..2739095 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/dao/MembershipDAOImpl.java @@ -0,0 +1,18 @@ +package com.example.demo.dao; + +import org.springframework.stereotype.Repository; + +@Repository +public class MembershipDAOImpl implements MembershipDAO { + @Override + public boolean addSillyMember() { + System.out.println(getClass() + ": DOING MY DB WORK: ADDING A MEMBER"); + return true; + } + + @Override + public void goToSleep() { + System.out.println(getClass() + ": I'm going to sleep now..."); + } + +} diff --git a/apps/springbootup/src/main/java/com/example/demo/dao/README.md b/apps/springbootup/src/main/java/com/example/demo/dao/README.md new file mode 100644 index 0000000..65d9c62 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/dao/README.md @@ -0,0 +1,45 @@ +# DAO (Data Access Object) Layer + +## Complex Database Operations + +```java +@Repository +public class EmployeeDAO { + @PersistenceContext + private EntityManager entityManager; + + public List findEmployeesByComplexCriteria() { + // Custom complex query implementation + } +} +``` + +## Multiple Data Sources + +```java +@Repository +public class EmployeeDAO { + @Autowired + private DataSource primaryDS; + + @Autowired + private DataSource secondaryDS; + + // Custom implementations for different data sources +} +``` + +## Best Practices + +```java +@Repository +public interface EmployeeRepository extends JpaRepository { + // Basic CRUD from JpaRepository +} + +@Repository +public class EmployeeDAO { + // Complex custom operations +} + +``` diff --git a/apps/springbootup/src/main/java/com/example/demo/dto/DepartmentDto.java b/apps/springbootup/src/main/java/com/example/demo/dto/DepartmentDto.java new file mode 100644 index 0000000..c55c83a --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/dto/DepartmentDto.java @@ -0,0 +1,12 @@ +package com.example.demo.dto; + +import lombok.Data; + +@Data +public class DepartmentDto { + private int id; + private String departmentCode; + private String departmentName; + private String location; + private Boolean active; +} diff --git a/apps/springbootup/src/main/java/com/example/demo/dto/EmployeeDto.java b/apps/springbootup/src/main/java/com/example/demo/dto/EmployeeDto.java new file mode 100644 index 0000000..034d49c --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/dto/EmployeeDto.java @@ -0,0 +1,12 @@ +package com.example.demo.dto; + +import lombok.Data; + +@Data +public class EmployeeDto { + private int id; + private String firstName; + private String lastName; + private String email; + private DepartmentDto department; +} diff --git a/apps/springbootup/src/main/java/com/example/demo/entity/Department.java b/apps/springbootup/src/main/java/com/example/demo/entity/Department.java new file mode 100644 index 0000000..56d63b1 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/entity/Department.java @@ -0,0 +1,56 @@ +package com.example.demo.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "department") +@EntityListeners(AuditingEntityListener.class) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Department { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @NotBlank(message = "Department code is required") + @Size(min = 2, max = 10, message = "Department code must be between 2 and 10 characters") + @Column(unique = true, nullable = false, length = 10) + private String departmentCode; + + @NotBlank(message = "Department name is required") + @Size(max = 50, message = "Department name must not exceed 50 characters") + @Column(nullable = false, length = 50) + private String departmentName; + + @Size(max = 100) + private String location; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "manager_id") + private Employee manager; + + @Column(nullable = false) + private boolean active; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime modifiedDate; +} diff --git a/apps/springbootup/src/main/java/com/example/demo/entity/Employee.java b/apps/springbootup/src/main/java/com/example/demo/entity/Employee.java new file mode 100644 index 0000000..8d72089 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/entity/Employee.java @@ -0,0 +1,41 @@ +package com.example.demo.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "employee") +public class Employee { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private int id; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Column(name = "email") + private String email; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_id") + private Department department; +} diff --git a/apps/springbootup/src/main/java/com/example/demo/health/CustomHealthIndicator.java.bak b/apps/springbootup/src/main/java/com/example/demo/health/CustomHealthIndicator.java.bak new file mode 100644 index 0000000..40d3990 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/health/CustomHealthIndicator.java.bak @@ -0,0 +1,26 @@ +package com.example.demo.health; + +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component +public class CustomHealthIndicator implements HealthIndicator { + + @Override + public Health health() { + // Perform your health check logic here + boolean isHealthy = performHealthCheck(); + + if (isHealthy) { + return Health.up().withDetail("message", "Application is healthy").build(); + } else { + return Health.down().withDetail("message", "Application is not healthy").build(); + } + } + + private boolean performHealthCheck() { + // Implement your health check logic here + return true; // Return true if healthy, false otherwise + } +} diff --git a/apps/springbootup/src/main/java/com/example/demo/repositories/DepartmentRepository.java b/apps/springbootup/src/main/java/com/example/demo/repositories/DepartmentRepository.java new file mode 100644 index 0000000..e5c2008 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/repositories/DepartmentRepository.java @@ -0,0 +1,11 @@ +package com.example.demo.repositories; + +import org.springframework.stereotype.Repository; + +import com.example.demo.entity.Department; + +import org.springframework.data.jpa.repository.JpaRepository; + +@Repository +public interface DepartmentRepository extends JpaRepository { +} diff --git a/apps/springbootup/src/main/java/com/example/demo/repositories/EmployeeRepository.java b/apps/springbootup/src/main/java/com/example/demo/repositories/EmployeeRepository.java new file mode 100644 index 0000000..4a53421 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/repositories/EmployeeRepository.java @@ -0,0 +1,10 @@ +package com.example.demo.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.example.demo.entity.Employee; + +@Repository +public interface EmployeeRepository extends JpaRepository { +} diff --git a/apps/springbootup/src/main/java/com/example/demo/services/department/DepartmentService.java b/apps/springbootup/src/main/java/com/example/demo/services/department/DepartmentService.java new file mode 100644 index 0000000..43b6b97 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/services/department/DepartmentService.java @@ -0,0 +1,58 @@ +package com.example.demo.services.department; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.example.demo.common.EntityDtoMapper; +import com.example.demo.dto.DepartmentDto; +import com.example.demo.entity.Department; +import com.example.demo.repositories.DepartmentRepository; + +import jakarta.persistence.EntityNotFoundException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class DepartmentService { + + private final DepartmentRepository departmentRepository; + private final EntityDtoMapper mapper; + + public DepartmentDto getDepartment(int id) { + Department department = departmentRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Department not found")); + return mapper.convertToDto(department, DepartmentDto.class); + } + + public List getAllDepartments() { + return departmentRepository.findAll().stream() + .map(dept -> mapper.convertToDto(dept, DepartmentDto.class)) + .collect(Collectors.toList()); + } + + public List findAll() { + return departmentRepository.findAll(); + } + + public Department findById(int theId) { + return departmentRepository.findById(theId).orElse(null); + } + + public Department save(Department theDepartment) { + Department savedDepartment = departmentRepository.save(theDepartment); + return savedDepartment; + } + + public void deleteById(int theId) { + departmentRepository.deleteById(theId); + } + + public void deleteAll() { + departmentRepository.deleteAll(); + } + +} diff --git a/apps/springbootup/src/main/java/com/example/demo/services/employee/EmployeeService.java b/apps/springbootup/src/main/java/com/example/demo/services/employee/EmployeeService.java new file mode 100644 index 0000000..8dd3ed7 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/services/employee/EmployeeService.java @@ -0,0 +1,54 @@ +package com.example.demo.services.employee; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.example.demo.common.EntityDtoMapper; +import com.example.demo.dto.EmployeeDto; +import com.example.demo.entity.Employee; +import com.example.demo.repositories.EmployeeRepository; + +import jakarta.persistence.EntityNotFoundException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class EmployeeService { + + private final EmployeeRepository employeeRepository; + private final EntityDtoMapper mapper; + + public EmployeeDto getEmployee(int id) { + Employee employee = employeeRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Employee not found")); + return mapper.convertToDto(employee, EmployeeDto.class); + } + + public List getAllEmployees() { + return employeeRepository.findAll().stream() + .map(emp -> mapper.convertToDto(emp, EmployeeDto.class)) + .collect(Collectors.toList()); + } + + public List findAll() { + return employeeRepository.findAll(); + } + + public Employee findById(int theId) { + return employeeRepository.findById(theId).orElse(null); + } + + public Employee save(Employee theEmployee) { + Employee savedEmployee = employeeRepository.save(theEmployee); + return savedEmployee; + } + + public void deleteById(int theId) { + employeeRepository.deleteById(theId); + } + +} diff --git a/apps/springbootup/src/main/java/com/example/demo/services/trafficfortune/TrafficFortuneService.java b/apps/springbootup/src/main/java/com/example/demo/services/trafficfortune/TrafficFortuneService.java new file mode 100644 index 0000000..4086aa1 --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/services/trafficfortune/TrafficFortuneService.java @@ -0,0 +1,7 @@ +package com.example.demo.services.trafficfortune; + +public interface TrafficFortuneService { + String getFortune(); + + String getFortune(boolean tripWire); +} diff --git a/apps/springbootup/src/main/java/com/example/demo/services/trafficfortune/TrafficFortuneServiceImpl.java b/apps/springbootup/src/main/java/com/example/demo/services/trafficfortune/TrafficFortuneServiceImpl.java new file mode 100644 index 0000000..be4e00e --- /dev/null +++ b/apps/springbootup/src/main/java/com/example/demo/services/trafficfortune/TrafficFortuneServiceImpl.java @@ -0,0 +1,30 @@ +package com.example.demo.services.trafficfortune; + +import java.util.concurrent.TimeUnit; + +import org.springframework.stereotype.Service; + +@Service +public class TrafficFortuneServiceImpl implements TrafficFortuneService { + + @Override + public String getFortune() { + // simulate a delay + try { + TimeUnit.SECONDS.sleep(5); + } catch (InterruptedException e) { + e.printStackTrace(); + } + // return a fortune + return "Expect heavy traffic this morning"; + } + + @Override + public String getFortune(boolean tripWire) { + if (tripWire) { + throw new RuntimeException("Major accident! Highway is closed!"); + } + return getFortune(); + } + +} diff --git a/apps/springbootup/src/main/resources/application.properties b/apps/springbootup/src/main/resources/application.properties new file mode 100644 index 0000000..75597dd --- /dev/null +++ b/apps/springbootup/src/main/resources/application.properties @@ -0,0 +1,36 @@ +spring.application.name=springbootup +# Enable the health endpoint +management.endpoint.health.enabled=true + +# Expose the health endpoint +management.endpoints.web.exposure.include=health + +# Show details in the health endpoint +management.endpoint.health.show-details=always + +# Turn off the Spring Boot banner +spring.main.banner-mode=off + +# H2 Database Configuration +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.username=dbuser +spring.datasource.password=dbpass +spring.datasource.driver-class-name=org.h2.Driver +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.h2.console.enabled=true +spring.jpa.hibernate.ddl-auto=update +spring.jpa.defer-datasource-initialization=true +spring.sql.init.mode=always +spring.output.ansi.enabled=ALWAYS + +# MySQL Database Configuration +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect +spring.jpa.show-sql=false +spring.jpa.open-in-view=false +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.jdbc.batch_size=500 +logging.level.root=DEBUG +logging.level.org.springframework=INFO +logging.level.org.hibernate.type=DEBUG +logging.level.org.hibernate.resource=INFO diff --git a/apps/springbootup/src/main/resources/data.sql b/apps/springbootup/src/main/resources/data.sql new file mode 100644 index 0000000..7e5befb --- /dev/null +++ b/apps/springbootup/src/main/resources/data.sql @@ -0,0 +1,4 @@ +INSERT INTO employee (first_name, last_name, email) VALUES +('John', 'Doe', 'john@example.com'), +('Jane', 'Doe', 'jane@example.com'), +('Alice', 'Smith', 'alice@example.com'); diff --git a/apps/springbootup/src/main/resources/schema.sql b/apps/springbootup/src/main/resources/schema.sql new file mode 100644 index 0000000..419f0c9 --- /dev/null +++ b/apps/springbootup/src/main/resources/schema.sql @@ -0,0 +1,29 @@ +-- First, create the Department table (parent table) +CREATE TABLE IF NOT EXISTS department ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + department_code VARCHAR(10) NOT NULL UNIQUE, + department_name VARCHAR(50) NOT NULL, + location VARCHAR(100), + manager_id INT, + active BOOLEAN DEFAULT true, + created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + modified_date TIMESTAMP +); + +-- Then, modify the Employee table to include the department reference +CREATE TABLE IF NOT EXISTS employee ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + department_id INT, + CONSTRAINT fk_employee_department + FOREIGN KEY (department_id) + REFERENCES department(id) +); + +-- Add the manager reference to department after employee table is created +ALTER TABLE department +ADD CONSTRAINT fk_department_manager + FOREIGN KEY (manager_id) + REFERENCES employee(id); diff --git a/apps/springbootup/src/test/java/com/example/demo/SpringbootupApplicationTests.java b/apps/springbootup/src/test/java/com/example/demo/SpringbootupApplicationTests.java new file mode 100644 index 0000000..f84a6d2 --- /dev/null +++ b/apps/springbootup/src/test/java/com/example/demo/SpringbootupApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.demo; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SpringbootupApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/package.json b/package.json index 5fa53f3..96af63d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "grpc:serve:aggregator": "nx run grpc:serve:aggregator", "grpc:build": "cross-env \"nx run grpc:clean && nx run grpc:build\"", "grpc:test": "nx run grpc:test", - "grpc:clean": "nx run grpc:clean" + "grpc:clean": "nx run grpc:clean", + "springbootup:serve": "nx run springbootup:serve", + "springbootup:build": "cross-env \"nx run springbootup:clean && nx run springbootup:build\"", + "springbootup:test": "nx run springbootup:test", + "springbootup:clean": "nx run springbootup:clean" }, "private": true, "devDependencies": { diff --git a/pom.xml b/pom.xml index 824fa29..9c755fa 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,7 @@ pom apps/grpc + apps/springbootup 25 From 7d0e2ad3781db0ebb3eae7c41fe7f470556eff80 Mon Sep 17 00:00:00 2001 From: "MSI\\guanxin" Date: Mon, 2 Feb 2026 09:50:46 +0800 Subject: [PATCH 5/6] fix: update to use java 25 --- apps/springbootup/pom.xml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/springbootup/pom.xml b/apps/springbootup/pom.xml index 2b10368..a3bb5e1 100644 --- a/apps/springbootup/pom.xml +++ b/apps/springbootup/pom.xml @@ -27,12 +27,22 @@ + + 25 + 25 + 25 + UTF-8 + 4.0.2 + 3.2.0 + 2.6.0 + 4.9 + org.springframework.boot spring-boot-dependencies - 4.0.2 + ${spring-boot.version} pom import @@ -42,7 +52,6 @@ org.springframework.boot spring-boot-starter-data-jpa - 4.0.2 @@ -64,19 +73,16 @@ org.springframework.boot spring-boot-starter-web - 4.0.2 org.springframework.boot spring-boot-starter-actuator - 4.0.2 org.springframework.boot spring-boot-starter-test - 4.0.2 test @@ -88,22 +94,21 @@ net.logstash.logback logstash-logback-encoder - 4.9 + ${logstash-logback-encoder.version} org.springframework.boot spring-boot-starter-validation - 4.0.2 org.modelmapper modelmapper - 3.2.0 + ${modelmapper.version} org.springdoc springdoc-openapi-starter-webmvc-ui - 2.6.0 + ${springdoc.version} From e6f468ddbf7392b7172a8cf2fc554adb2fbcce4c Mon Sep 17 00:00:00 2001 From: "MSI\\guanxin" Date: Mon, 2 Feb 2026 09:55:01 +0800 Subject: [PATCH 6/6] feat: create readme.md --- apps/springbootup/README.md | 297 ++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 apps/springbootup/README.md diff --git a/apps/springbootup/README.md b/apps/springbootup/README.md new file mode 100644 index 0000000..efb6813 --- /dev/null +++ b/apps/springbootup/README.md @@ -0,0 +1,297 @@ +# Spring Boot Demo Application + +A comprehensive Spring Boot 4.0.2 demo application showcasing modern enterprise Java development practices with REST APIs, JPA/Hibernate, and various Spring Boot features. + +## ๐Ÿ“‹ Table of Contents + +- [Overview](#overview) +- [Technologies](#technologies) +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Getting Started](#getting-started) +- [Project Structure](#project-structure) +- [API Endpoints](#api-endpoints) +- [Configuration](#configuration) +- [Development](#development) +- [Testing](#testing) +- [Building](#building) +- [Documentation](#documentation) + +## ๐ŸŽฏ Overview + +This is a demo Spring Boot application that demonstrates: +- RESTful API development +- JPA/Hibernate for database operations +- Employee and Department management system +- Spring AOP for cross-cutting concerns +- API documentation with SpringDoc OpenAPI +- Health monitoring with Spring Boot Actuator +- JSON logging with Logstash encoder + +## ๐Ÿ›  Technologies + +- **Java**: 25 +- **Spring Boot**: 4.0.2 +- **Database**: H2 (in-memory) / MySQL +- **Build Tool**: Maven +- **ORM**: Hibernate/JPA +- **API Documentation**: SpringDoc OpenAPI 2.6.0 +- **Mapping**: ModelMapper 3.2.0 +- **Logging**: Logback with Logstash encoder 4.9 +- **Monitoring**: Spring Boot Actuator +- **Code Generation**: Lombok + +## โœจ Features + +### Core Functionality +- **Employee Management**: CRUD operations for employees +- **Department Management**: Department tracking and relationships +- **RESTful APIs**: Clean, resource-oriented endpoints +- **Data Validation**: Jakarta Bean Validation +- **Exception Handling**: Global exception handler +- **DTO Pattern**: Entity-DTO mapping for clean API contracts + +### Technical Features +- **Spring AOP**: Aspect-oriented programming support +- **JPA Auditing**: Automatic tracking of entity changes +- **H2 Console**: In-memory database management interface +- **Health Checks**: Spring Boot Actuator health endpoints +- **OpenAPI/Swagger**: Interactive API documentation +- **JSON Logging**: Structured logging for analysis + +## ๐Ÿ“ฆ Prerequisites + +- Java 25 or higher +- Maven 3.6+ (or use the Nx wrapper) +- H2 Database (embedded) or MySQL (optional) + +## ๐Ÿš€ Getting Started + +### Clone and Navigate + +```bash +cd c:/software-dev/gx.java/apps/springbootup +``` + +### Build the Application + +Using Nx (recommended): +```bash +nx run springbootup:build +``` + +Or using npm/pnpm: +```bash +pnpm run springbootup:build +``` + +Or using Maven directly: +```bash +mvn clean package +``` + +### Run the Application + +Using Maven: +```bash +mvn spring-boot:run +``` + +Or run the built JAR: +```bash +java -jar target/demo-0.0.1-SNAPSHOT.jar +``` + +The application will start on `http://localhost:8080` + +## ๐Ÿ“ Project Structure + +``` +src/ +โ”œโ”€โ”€ main/ +โ”‚ โ”œโ”€โ”€ java/com/example/demo/ +โ”‚ โ”‚ โ”œโ”€โ”€ aspect/ # AOP aspects +โ”‚ โ”‚ โ”œโ”€โ”€ common/ # Common utilities, mappers, exception handlers +โ”‚ โ”‚ โ”œโ”€โ”€ config/ # Configuration classes +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ AopConfig.java +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ DatabaseConfig.java +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ModelMapperConfig.java +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ OpenAPIConfig.java +โ”‚ โ”‚ โ”œโ”€โ”€ controllers/ # REST controllers +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ EmployeeController.java +โ”‚ โ”‚ โ”œโ”€โ”€ dao/ # Data Access Objects +โ”‚ โ”‚ โ”œโ”€โ”€ dto/ # Data Transfer Objects +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ EmployeeDto.java +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ DepartmentDto.java +โ”‚ โ”‚ โ”œโ”€โ”€ entity/ # JPA entities +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Employee.java +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ Department.java +โ”‚ โ”‚ โ”œโ”€โ”€ health/ # Custom health indicators +โ”‚ โ”‚ โ”œโ”€โ”€ repositories/ # Spring Data JPA repositories +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ EmployeeRepository.java +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ DepartmentRepository.java +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # Business logic services +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ employee/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ department/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ trafficfortune/ +โ”‚ โ”‚ โ””โ”€โ”€ SpringbootupApplication.java +โ”‚ โ””โ”€โ”€ resources/ +โ”‚ โ”œโ”€โ”€ application.properties # Application configuration +โ”‚ โ”œโ”€โ”€ data.sql # Initial data +โ”‚ โ””โ”€โ”€ schema.sql # Database schema +โ””โ”€โ”€ test/ # Test classes +``` + +## ๐Ÿ”Œ API Endpoints + +### Employee Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/employees/list` | Get all employees | +| GET | `/employees/get/{id}` | Get employee by ID | +| POST | `/employees/save` | Create or update employee | +| GET | `/employees/delete/{id}` | Delete employee | + +### Health & Monitoring + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/actuator/health` | Application health status | + +### API Documentation + +- **Swagger UI**: `http://localhost:8080/swagger-ui.html` +- **OpenAPI JSON**: `http://localhost:8080/v3/api-docs` + +## โš™๏ธ Configuration + +### Database Configuration + +#### H2 (Default - In-Memory) +```properties +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.username=dbuser +spring.datasource.password=dbpass +spring.h2.console.enabled=true +``` + +Access H2 Console at: `http://localhost:8080/h2-console` + +#### MySQL (Alternative) +Update `application.properties` with MySQL connection details: +```properties +spring.datasource.url=jdbc:mysql://localhost:3306/your_database +spring.datasource.username=your_username +spring.datasource.password=your_password +``` + +### Application Properties + +Key configurations in `application.properties`: +- **Port**: Default is 8080 +- **JPA**: Hibernate DDL auto-update enabled +- **Actuator**: Health endpoint exposed +- **Logging**: DEBUG level for root, INFO for Spring +- **H2 Console**: Enabled for development + +## ๐Ÿ”ง Development + +### Enable Development Mode + +The application uses Spring Boot DevTools (when available) for: +- Automatic restarts +- LiveReload support +- Enhanced development experience + +### Code Style + +The project uses: +- **Lombok**: Reduce boilerplate code with annotations +- **Builder Pattern**: For entity construction +- **DTO Pattern**: Separate API contracts from domain models + +### Adding New Features + +1. Create entity in `entity/` package +2. Create corresponding DTO in `dto/` package +3. Create repository extending `JpaRepository` +4. Implement service in `services/` package +5. Create REST controller in `controllers/` package +6. Add tests in `src/test/` + +## ๐Ÿงช Testing + +Run tests using: + +```bash +# Using Nx +nx run springbootup:test + +# Using Maven +mvn test +``` + +## ๐Ÿ—๏ธ Building + +### Development Build +```bash +nx run springbootup:build +``` + +### Production Build +```bash +mvn clean package -DskipTests +``` + +The JAR file will be created in `target/demo-0.0.1-SNAPSHOT.jar` + +## ๐Ÿ“š Documentation + +### Swagger/OpenAPI Documentation + +Once the application is running, visit: +- **Swagger UI**: http://localhost:8080/swagger-ui.html +- **OpenAPI Spec**: http://localhost:8080/v3/api-docs + +The OpenAPI documentation is auto-generated and includes: +- All REST endpoints +- Request/response schemas +- Try-it-out functionality +- Model definitions + +### Actuator Endpoints + +Monitor application health: +- **Health**: http://localhost:8080/actuator/health + +## ๐Ÿ” Security Notes + +This is a demo application. For production use, consider: +- Adding Spring Security +- Implementing authentication/authorization +- Securing actuator endpoints +- Using environment-specific configurations +- Enabling HTTPS +- Implementing rate limiting + +## ๐Ÿ“„ License + +This is a demo project for educational purposes. + +## ๐Ÿค Contributing + +1. Create a feature branch +2. Make your changes +3. Add tests +4. Submit a pull request + +## ๐Ÿ“ž Support + +For issues or questions, please refer to the main project documentation or create an issue in the repository. + +--- + +**Version**: 0.0.1-SNAPSHOT +**Artifact ID**: demo +**Group ID**: com.springboot.collection