diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df34a5a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/.git +**/.idea +**/*.iml +**/target +!*/target/*.jar +**/node_modules +**/.env +**/run-logs +**/load-tests +**/k8s diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a87b964..b8b6e63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI — Build & Verify +name: CI — Build, Test & Publish on: push: @@ -6,9 +6,14 @@ on: pull_request: branches: [ main ] +env: + REGISTRY: ghcr.io + IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }} + jobs: - build: - name: Build all modules (Java 21 + Maven) + # ── Job 1: compile + test (runs on every push/PR) ───────────────────────── + build-and-test: + name: Build & Test (Java 21 + Maven) runs-on: ubuntu-latest steps: @@ -22,7 +27,7 @@ jobs: distribution: 'temurin' cache: maven - - name: Build all modules + - name: Build all modules (skip tests) run: mvn clean package -DskipTests --no-transfer-progress - name: Run unit tests @@ -37,3 +42,127 @@ jobs: */target/*.jar !*/target/*-sources.jar retention-days: 7 + + # ── Job 2: build + push Docker images (main branch only) ────────────────── + docker-publish: + name: Build & Push Docker Images + runs-on: ubuntu-latest + needs: build-and-test + # Only run on pushes to main (not PRs) to avoid publishing untested images + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + permissions: + contents: read + packages: write + + strategy: + # Build all services in parallel — fail-fast: false so one failure + # doesn't cancel all other service builds + fail-fast: false + matrix: + service: + - name: service-registry + port: 8761 + - name: api-gateway + port: 8080 + - name: auth-service + port: 8086 + - name: order-service + port: 8081 + - name: inventory-service + port: 8082 + - name: payment-service + port: 8083 + - name: notification-service + port: 8084 + - name: shipping-service + port: 8085 + - name: user-service + port: 8087 + - name: product-service + port: 8088 + - name: cart-service + port: 8089 + - name: wishlist-service + port: 8090 + - name: seller-service + port: 8091 + - name: logging-service + port: 8092 + - name: analytics-service + port: 8093 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata (tags + labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_PREFIX }}/${{ matrix.service.name }} + tags: | + # Tag with short SHA so every commit is traceable + type=sha,prefix=sha- + # Tag latest on main + type=raw,value=latest,enable={{is_default_branch}} + # Semantic version tags when a git tag is pushed (e.g. v1.2.3) + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push ${{ matrix.service.name }} + uses: docker/build-push-action@v5 + with: + context: . + file: ./${{ matrix.service.name }}/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # BuildKit inline cache — reuse layers from previous builds + cache-from: type=registry,ref=${{ env.IMAGE_PREFIX }}/${{ matrix.service.name }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_PREFIX }}/${{ matrix.service.name }}:buildcache,mode=max + build-args: | + BUILD_DATE=${{ github.event.head_commit.timestamp }} + GIT_COMMIT=${{ github.sha }} + + # ── Job 3: security scan (main branch only) ─────────────────────────────── + security-scan: + name: Security Scan (Trivy) + runs-on: ubuntu-latest + needs: docker-publish + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + strategy: + fail-fast: false + matrix: + service: + - order-service + - auth-service + - payment-service + - api-gateway + + steps: + - name: Run Trivy vulnerability scan on ${{ matrix.service }} + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:latest + format: sarif + output: trivy-${{ matrix.service }}.sarif + severity: CRITICAL,HIGH + exit-code: '0' # Don't fail the build, just report + + - name: Upload Trivy SARIF to GitHub Security + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-${{ matrix.service }}.sarif + category: trivy-${{ matrix.service }} diff --git a/SagaFlow.postman_collection.json b/SagaFlow.postman_collection.json index 5184ad1..e79e0a4 100644 --- a/SagaFlow.postman_collection.json +++ b/SagaFlow.postman_collection.json @@ -1,4 +1,4 @@ -{ +{ "info": { "name": "EventDrivenMesh - Order Saga Flow", "description": "Clean 11-step end-to-end Order Saga flow. Run via Collection Runner in order.", @@ -30,7 +30,7 @@ "request": { "method": "POST", "header": [{ "key": "Content-Type", "value": "application/json" }], - "url": { "raw": "{{baseUrl}}/api/auth/login", "host": ["{{baseUrl}}"], "path": ["api","auth","login"] }, + "url": { "raw": "{{baseUrl}}/api/auth/login", "host": ["{{baseUrl}}"], "path": ["api","v1","auth","login"] }, "body": { "mode": "raw", "raw": "{\n \"email\": \"testuser@example.com\",\n \"password\": \"Password@123\"\n}" } } }, @@ -48,7 +48,7 @@ { "key": "Content-Type", "value": "application/json" }, { "key": "Authorization", "value": "Bearer {{accessToken}}" } ], - "url": { "raw": "{{baseUrl}}/api/products/categories", "host": ["{{baseUrl}}"], "path": ["api","products","categories"] }, + "url": { "raw": "{{baseUrl}}/api/products/categories", "host": ["{{baseUrl}}"], "path": ["api","v1","products","categories"] }, "body": { "mode": "raw", "raw": "{\n \"name\": \"Smartphones\",\n \"description\": \"Mobile phones and smartphones\"\n}" } } }, @@ -67,7 +67,7 @@ { "key": "Authorization", "value": "Bearer {{accessToken}}" }, { "key": "X-User-Id", "value": "{{userId}}" } ], - "url": { "raw": "{{baseUrl}}/api/products", "host": ["{{baseUrl}}"], "path": ["api","products"] }, + "url": { "raw": "{{baseUrl}}/api/products", "host": ["{{baseUrl}}"], "path": ["api","v1","products"] }, "body": { "mode": "raw", "raw": "{\n \"name\": \"Samsung Galaxy S25\",\n \"description\": \"Latest flagship smartphone\",\n \"price\": 79999.00,\n \"sku\": \"SGS25-BLK-256\",\n \"brand\": \"Samsung\",\n \"categoryId\": \"{{categoryId}}\",\n \"stockQuantity\": 100\n}" } } }, @@ -85,7 +85,7 @@ { "key": "Content-Type", "value": "application/json" }, { "key": "Authorization", "value": "Bearer {{accessToken}}" } ], - "url": { "raw": "{{baseUrl}}/api/inventory", "host": ["{{baseUrl}}"], "path": ["api","inventory"] }, + "url": { "raw": "{{baseUrl}}/api/inventory", "host": ["{{baseUrl}}"], "path": ["api","v1","inventory"] }, "body": { "mode": "raw", "raw": "{\n \"productId\": \"{{productId}}\",\n \"quantity\": 100,\n \"lowStockThreshold\": 10,\n \"warehouseLocation\": \"Warehouse-A\"\n}" } } }, @@ -108,7 +108,7 @@ { "key": "Content-Type", "value": "application/json" }, { "key": "Authorization", "value": "Bearer {{accessToken}}" } ], - "url": { "raw": "{{baseUrl}}/api/orders", "host": ["{{baseUrl}}"], "path": ["api","orders"] }, + "url": { "raw": "{{baseUrl}}/api/orders", "host": ["{{baseUrl}}"], "path": ["api","v1","orders"] }, "body": { "mode": "raw", "raw": "{\n \"customerId\": \"{{userId}}\",\n \"items\": [\n {\n \"productId\": \"{{productId}}\",\n \"productName\": \"Samsung Galaxy S25\",\n \"quantity\": 1,\n \"price\": 79999.00\n }\n ],\n \"shippingAddress\": {\n \"street\": \"123 MG Road\",\n \"city\": \"Bangalore\",\n \"state\": \"Karnataka\",\n \"pincode\": \"560001\",\n \"country\": \"India\"\n },\n \"totalAmount\": 79999.00\n}" } } }, @@ -123,7 +123,7 @@ "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], - "url": { "raw": "{{baseUrl}}/api/orders/{{orderId}}", "host": ["{{baseUrl}}"], "path": ["api","orders","{{orderId}}"] } + "url": { "raw": "{{baseUrl}}/api/orders/{{orderId}}", "host": ["{{baseUrl}}"], "path": ["api","v1","orders","{{orderId}}"] } } }, { @@ -142,7 +142,7 @@ { "key": "Content-Type", "value": "application/json" }, { "key": "Authorization", "value": "Bearer {{accessToken}}" } ], - "url": { "raw": "{{baseUrl}}/api/payments/initiate", "host": ["{{baseUrl}}"], "path": ["api","payments","initiate"] }, + "url": { "raw": "{{baseUrl}}/api/payments/initiate", "host": ["{{baseUrl}}"], "path": ["api","v1","payments","initiate"] }, "body": { "mode": "raw", "raw": "{\n \"orderId\": \"{{orderId}}\",\n \"customerId\": \"{{userId}}\",\n \"amount\": 79999.00,\n \"currency\": \"INR\",\n \"gateway\": \"MOCK\"\n}" } } }, @@ -157,7 +157,7 @@ "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], - "url": { "raw": "{{baseUrl}}/api/inventory/product/{{productId}}", "host": ["{{baseUrl}}"], "path": ["api","inventory","product","{{productId}}"] } + "url": { "raw": "{{baseUrl}}/api/inventory/product/{{productId}}", "host": ["{{baseUrl}}"], "path": ["api","v1","inventory","product","{{productId}}"] } } }, { @@ -173,7 +173,7 @@ "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], - "url": { "raw": "{{baseUrl}}/api/shipping/order/{{orderId}}", "host": ["{{baseUrl}}"], "path": ["api","shipping","order","{{orderId}}"] } + "url": { "raw": "{{baseUrl}}/api/shipping/order/{{orderId}}", "host": ["{{baseUrl}}"], "path": ["api","v1","shipping","order","{{orderId}}"] } } }, { @@ -191,7 +191,7 @@ { "key": "Authorization", "value": "Bearer {{accessToken}}" }, { "key": "X-User-Id", "value": "{{userId}}" } ], - "url": { "raw": "{{baseUrl}}/api/notifications", "host": ["{{baseUrl}}"], "path": ["api","notifications"] } + "url": { "raw": "{{baseUrl}}/api/notifications", "host": ["{{baseUrl}}"], "path": ["api","v1","notifications"] } } }, { @@ -205,7 +205,7 @@ "request": { "method": "GET", "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], - "url": { "raw": "{{baseUrl}}/api/notifications/invoice/{{orderId}}", "host": ["{{baseUrl}}"], "path": ["api","notifications","invoice","{{orderId}}"] } + "url": { "raw": "{{baseUrl}}/api/notifications/invoice/{{orderId}}", "host": ["{{baseUrl}}"], "path": ["api","v1","notifications","invoice","{{orderId}}"] } } } ] diff --git a/analytics-service/Dockerfile b/analytics-service/Dockerfile new file mode 100644 index 0000000..f47310d --- /dev/null +++ b/analytics-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY analytics-service/pom.xml analytics-service/ +COPY analytics-service/src analytics-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,analytics-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/analytics-service/target/*.jar app.jar + +EXPOSE 8093 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/analytics-service/pom.xml b/analytics-service/pom.xml index e36a223..bb6aa73 100644 --- a/analytics-service/pom.xml +++ b/analytics-service/pom.xml @@ -62,14 +62,12 @@ micrometer-registry-prometheus + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + - - - - - org.apache.maven.plugins - maven-compiler-plugin - org.springframework.boot spring-boot-maven-plugin diff --git a/analytics-service/src/main/java/com/hacisimsek/analytics/consumer/OrderAnalyticsConsumer.java b/analytics-service/src/main/java/com/hacisimsek/analytics/consumer/OrderAnalyticsConsumer.java index 2caae00..5f6cb92 100644 --- a/analytics-service/src/main/java/com/hacisimsek/analytics/consumer/OrderAnalyticsConsumer.java +++ b/analytics-service/src/main/java/com/hacisimsek/analytics/consumer/OrderAnalyticsConsumer.java @@ -145,3 +145,15 @@ private void sendToDlq(ConsumerRecord record, String reason) { } } } + +//POST /api/orders +// ↓ +//OrderController +// ↓ +//OrderService +// ↓ +//OrderRepository +// ↓ +//PostgreSQL +// ↓ +//Kafka Producer diff --git a/analytics-service/src/main/java/com/hacisimsek/analytics/controller/AnalyticsController.java b/analytics-service/src/main/java/com/hacisimsek/analytics/controller/AnalyticsController.java index 97566d9..58df4e9 100644 --- a/analytics-service/src/main/java/com/hacisimsek/analytics/controller/AnalyticsController.java +++ b/analytics-service/src/main/java/com/hacisimsek/analytics/controller/AnalyticsController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.analytics.controller; +package com.hacisimsek.analytics.controller; import com.hacisimsek.analytics.model.OrderAnalytics; import com.hacisimsek.analytics.repository.OrderAnalyticsRepository; @@ -14,7 +14,7 @@ import java.util.Map; @RestController -@RequestMapping("/api/analytics") +@RequestMapping("/api/v1/analytics") @RequiredArgsConstructor public class AnalyticsController { diff --git a/api-gateway/Dockerfile b/api-gateway/Dockerfile new file mode 100644 index 0000000..a48cf33 --- /dev/null +++ b/api-gateway/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY api-gateway/pom.xml api-gateway/ +COPY api-gateway/src api-gateway/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,api-gateway -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/api-gateway/target/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/api-gateway/pom.xml b/api-gateway/pom.xml index 1797832..9dd8c3e 100644 --- a/api-gateway/pom.xml +++ b/api-gateway/pom.xml @@ -49,6 +49,25 @@ ${jjwt.version} runtime + + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + + + com.hacisimsek + common-library + ${project.version} + + + + + org.springframework.kafka + spring-kafka + diff --git a/api-gateway/src/main/java/com/hacisimsek/apigateway/config/KafkaConfig.java b/api-gateway/src/main/java/com/hacisimsek/apigateway/config/KafkaConfig.java new file mode 100644 index 0000000..4df7758 --- /dev/null +++ b/api-gateway/src/main/java/com/hacisimsek/apigateway/config/KafkaConfig.java @@ -0,0 +1,40 @@ +package com.hacisimsek.apigateway.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +/** + * Kafka producer configuration for the API Gateway. + * Required by LogPublisher (from common-library) for structured access logging. + */ +@Configuration +public class KafkaConfig { + + @Value("${app.kafka.bootstrap-servers:localhost:9095}") + private String bootstrapServers; + + @Bean + public ProducerFactory producerFactory() { + Map config = new HashMap<>(); + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, true); + return new DefaultKafkaProducerFactory<>(config); + } + + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } +} diff --git a/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/AccessLogFilter.java b/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/AccessLogFilter.java new file mode 100644 index 0000000..50ef9d8 --- /dev/null +++ b/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/AccessLogFilter.java @@ -0,0 +1,93 @@ +package com.hacisimsek.apigateway.filter; + +import com.hacisimsek.common.logging.LogPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.Map; + +/** + * Global gateway filter that records a structured access log entry for every + * HTTP request that passes through the gateway. + * + * Logged fields: + * - method e.g. POST + * - path e.g. /api/v1/orders + * - httpStatus e.g. 201 + * - durationMs end-to-end latency in ms + * - userId from X-User-Id header (set by JwtAuthFilter after auth) + * - traceId from X-Trace-Id header (injected by JwtAuthFilter) + * - userAgent from User-Agent header + * + * Logs are published to the "service-logs" Kafka topic via LogPublisher and + * stored in MongoDB (logging_db) by the logging-service. + * + * Order: HIGHEST_PRECEDENCE + 1 — runs after any pre-processing but before routing, + * so it captures the full round-trip duration including downstream service time. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class AccessLogFilter implements GlobalFilter, Ordered { + + private static final String SERVICE_NAME = "api-gateway"; + + private final LogPublisher logPublisher; + + @Override + public int getOrder() { + // Run very early so we capture total latency including JwtAuthFilter + routing + return Ordered.HIGHEST_PRECEDENCE + 1; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + long startMs = System.currentTimeMillis(); + ServerHttpRequest request = exchange.getRequest(); + + String method = request.getMethod().name(); + String path = request.getURI().getPath(); + String traceId = request.getHeaders().getFirst("X-Trace-Id"); + String userId = request.getHeaders().getFirst("X-User-Id"); + + return chain.filter(exchange) + .doFinally(signalType -> { + ServerHttpResponse response = exchange.getResponse(); + long durationMs = System.currentTimeMillis() - startMs; + int statusCode = response.getStatusCode() != null + ? response.getStatusCode().value() + : 0; + + String level = statusCode >= 500 ? "ERROR" + : statusCode >= 400 ? "WARN" + : "INFO"; + + String message = String.format("%s %s → %d (%dms)", method, path, statusCode, durationMs); + + Map metadata = new java.util.LinkedHashMap<>(); + metadata.put("method", method); + metadata.put("path", path); + metadata.put("durationMs", durationMs); + if (userId != null) metadata.put("userId", userId); + + // Use the appropriate log level based on response status + switch (level) { + case "ERROR" -> logPublisher.error(SERVICE_NAME, traceId, message, metadata); + case "WARN" -> logPublisher.warn(SERVICE_NAME, traceId, message, metadata); + default -> logPublisher.info(SERVICE_NAME, traceId, message, metadata); + } + + // Also log locally at DEBUG so developers see it in console + log.debug("[ACCESS] {} {} {} {}ms traceId={} userId={}", + method, path, statusCode, durationMs, traceId, userId); + }); + } +} diff --git a/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/JwtAuthFilter.java b/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/JwtAuthFilter.java index ee89b2d..6b46d5a 100644 --- a/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/JwtAuthFilter.java +++ b/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/JwtAuthFilter.java @@ -10,6 +10,7 @@ import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -20,92 +21,118 @@ import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; - +import java.util.UUID; + +/** + * Gateway JWT filter — validates every protected request. + * + * Steps: + * 1. Extract Bearer token from Authorization header + * 2. Parse and verify JWT signature (hex-decoded HMAC-SHA key) + * 3. Check if the token's jti is in the Redis blacklist + * 4. Generate X-Trace-Id (UUID) and forward it downstream alongside + * X-User-Id / X-User-Email / X-User-Role + * + * X-Trace-Id correlates a single HTTP request across all downstream services. + * If the incoming request already carries an X-Trace-Id (e.g. from a load + * balancer or upstream proxy), that value is preserved and forwarded as-is. + */ @Component @Slf4j public class JwtAuthFilter extends AbstractGatewayFilterFactory { + private static final String BLACKLIST_PREFIX = "blacklist:"; + @Value("${app.jwt.secret}") private String jwtSecret; - public JwtAuthFilter() { + // Reactive Redis template — gateway is WebFlux (non-blocking) + private final ReactiveStringRedisTemplate redisTemplate; + + public JwtAuthFilter(ReactiveStringRedisTemplate redisTemplate) { super(Config.class); + this.redisTemplate = redisTemplate; } @Override public GatewayFilter apply(Config config) { - return (exchange, chain) -> { ServerHttpRequest request = exchange.getRequest(); - - String authHeader = - request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return unauthorized( - exchange.getResponse(), - "Missing or invalid Authorization header" - ); + return unauthorized(exchange.getResponse(), "Missing or invalid Authorization header"); } String token = authHeader.substring(7); + Claims claims; try { - - Claims claims = parseToken(token); - - ServerHttpRequest mutatedRequest = - request.mutate() - .header("X-User-Id", claims.getSubject()) - .header("X-User-Email", - claims.get("email", String.class)) - .header("X-User-Role", - claims.get("role", String.class)) - .build(); - - return chain.filter( - exchange.mutate() - .request(mutatedRequest) - .build() - ); - + claims = parseToken(token); } catch (ExpiredJwtException e) { - log.warn("JWT expired for request: {}", request.getURI()); - - return unauthorized( - exchange.getResponse(), - "JWT token has expired" - ); - + return unauthorized(exchange.getResponse(), "JWT token has expired"); } catch (MalformedJwtException | IllegalArgumentException e) { - log.warn("Invalid JWT for request: {}", request.getURI()); - - return unauthorized( - exchange.getResponse(), - "Invalid JWT token" - ); - + return unauthorized(exchange.getResponse(), "Invalid JWT token"); } catch (Exception e) { - log.error("JWT validation error: {}", e.getMessage()); - - return unauthorized( - exchange.getResponse(), - "JWT validation failed" - ); + return unauthorized(exchange.getResponse(), "JWT validation failed"); } + + // Check the Redis blacklist — non-blocking reactive check + String jti = claims.getId(); + String blacklistKey = BLACKLIST_PREFIX + jti; + + return redisTemplate.hasKey(blacklistKey) + .flatMap(isBlacklisted -> { + if (Boolean.TRUE.equals(isBlacklisted)) { + log.warn("Rejected blacklisted token jti={} for {}", + jti, request.getURI()); + return unauthorized(exchange.getResponse(), "Token has been revoked"); + } + + // Reuse existing trace ID if present (from upstream proxy/LB), + // otherwise generate a fresh UUID for this request + String traceId = request.getHeaders().getFirst("X-Trace-Id"); + if (traceId == null || traceId.isBlank()) { + traceId = UUID.randomUUID().toString(); + } + + ServerHttpRequest mutatedRequest = request.mutate() + .header("X-User-Id", claims.getSubject()) + .header("X-User-Email", claims.get("email", String.class)) + .header("X-User-Role", claims.get("role", String.class)) + .header("X-Trace-Id", traceId) + .build(); + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + }) + .onErrorResume(e -> { + log.warn("Redis blacklist check failed — proceeding without blacklist check: {}", + e.getMessage()); + + String traceId = request.getHeaders().getFirst("X-Trace-Id"); + if (traceId == null || traceId.isBlank()) { + traceId = UUID.randomUUID().toString(); + } + + ServerHttpRequest mutatedRequest = request.mutate() + .header("X-User-Id", claims.getSubject()) + .header("X-User-Email", claims.get("email", String.class)) + .header("X-User-Role", claims.get("role", String.class)) + .header("X-Trace-Id", traceId) + .build(); + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + }); }; } private Claims parseToken(String token) { - byte[] keyBytes = hexStringToByteArray(jwtSecret); - SecretKey key = Keys.hmacShaKeyFor(keyBytes); - return Jwts.parser() .verifyWith(key) .build() @@ -114,42 +141,26 @@ private Claims parseToken(String token) { } private byte[] hexStringToByteArray(String hex) { - int len = hex.length(); - byte[] data = new byte[len / 2]; - for (int i = 0; i < len; i += 2) { - - data[i / 2] = (byte) - ((Character.digit(hex.charAt(i), 16) << 4) - + Character.digit(hex.charAt(i + 1), 16)); + data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + + Character.digit(hex.charAt(i + 1), 16)); } - return data; } - private Mono unauthorized( - ServerHttpResponse response, - String message - ) { - + private Mono unauthorized(ServerHttpResponse response, String message) { response.setStatusCode(HttpStatus.UNAUTHORIZED); - - response.getHeaders() - .setContentType(MediaType.APPLICATION_JSON); - + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); String body = """ {"success":false,"message":"%s"} """.formatted(message); - - DataBuffer buffer = - response.bufferFactory() - .wrap(body.getBytes(StandardCharsets.UTF_8)); - + DataBuffer buffer = response.bufferFactory() + .wrap(body.getBytes(StandardCharsets.UTF_8)); return response.writeWith(Mono.just(buffer)); } public static class Config { } -} \ No newline at end of file +} diff --git a/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/RoleAuthorizationFilter.java b/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/RoleAuthorizationFilter.java new file mode 100644 index 0000000..557a03c --- /dev/null +++ b/api-gateway/src/main/java/com/hacisimsek/apigateway/filter/RoleAuthorizationFilter.java @@ -0,0 +1,97 @@ +package com.hacisimsek.apigateway.filter; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +/** + * Gateway filter that enforces role-based access control on individual routes. + * + * Usage in application.yml: + *
+ *   filters:
+ *     - JwtAuthFilter                      # validates token, injects X-User-Role
+ *     - name: RoleAuthorizationFilter
+ *       args:
+ *         roles: ROLE_ADMIN                # comma-separated list of allowed roles
+ * 
+ * + * The JwtAuthFilter must run first — it injects the X-User-Role header which + * this filter reads. If the role is not in the allowed list, a 403 is returned + * immediately without forwarding to the downstream service. + * + * Role values match the Role enum in auth-service: ROLE_USER, ROLE_SELLER, ROLE_ADMIN. + */ +@Component +@Slf4j +public class RoleAuthorizationFilter + extends AbstractGatewayFilterFactory { + + public RoleAuthorizationFilter() { + super(Config.class); + } + + @Override + public GatewayFilter apply(Config config) { + return (exchange, chain) -> { + String userRole = exchange.getRequest().getHeaders().getFirst("X-User-Role"); + + if (userRole == null || userRole.isBlank()) { + log.warn("[RoleAuth] X-User-Role header missing — JwtAuthFilter must run first"); + return forbidden(exchange.getResponse(), "Access denied — role not determined"); + } + + List allowed = config.getRoles(); + if (!allowed.contains(userRole)) { + log.warn("[RoleAuth] Role '{}' not in allowed list {} for path {}", + userRole, allowed, exchange.getRequest().getURI().getPath()); + return forbidden(exchange.getResponse(), + "Access denied — required role: " + String.join(" or ", allowed)); + } + + return chain.filter(exchange); + }; + } + + private Mono forbidden(ServerHttpResponse response, String message) { + response.setStatusCode(HttpStatus.FORBIDDEN); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + String body = """ + {"success":false,"message":"%s"} + """.formatted(message); + DataBuffer buffer = response.bufferFactory() + .wrap(body.getBytes(StandardCharsets.UTF_8)); + return response.writeWith(Mono.just(buffer)); + } + + @Override + public List shortcutFieldOrder() { + return List.of("roles"); + } + + public static class Config { + /** Comma-separated allowed roles, e.g. "ROLE_ADMIN" or "ROLE_ADMIN,ROLE_SELLER" */ + private List roles; + + public List getRoles() { + return roles; + } + + public void setRoles(String rolesStr) { + this.roles = Arrays.stream(rolesStr.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + } +} diff --git a/api-gateway/src/main/resources/application.yml b/api-gateway/src/main/resources/application.yml index e8856cf..45cfe5b 100644 --- a/api-gateway/src/main/resources/application.yml +++ b/api-gateway/src/main/resources/application.yml @@ -5,7 +5,6 @@ spring: application: name: api-gateway - # ── Redis (used by RequestRateLimiter to store token buckets per IP) ──────── data: redis: host: ${REDIS_HOST:localhost} @@ -21,23 +20,21 @@ spring: enabled: true lower-case-service-id: true - # ── Default 429 response headers exposed to clients ─────────────────── default-filters: - name: RequestRateLimiter args: - # fallback limiter applied to any route not explicitly configured rate-limiter: "#{@standardLimiter}" key-resolver: "#{@ipKeyResolver}" - deny-empty-key: false # don't crash if IP can't be resolved + deny-empty-key: false empty-key-status: 429 routes: - # ── Auth Service — tightest limit (brute-force / spam protection) ─── + # Auth Service — tightest limit (brute-force / spam protection) - id: auth-service uri: lb://auth-service predicates: - - Path=/api/auth/** + - Path=/api/v1/auth/** filters: - name: RequestRateLimiter args: @@ -46,11 +43,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Razorpay Webhook — high limit, no JWT, never block Razorpay ───── + # Razorpay Webhook — high limit, no JWT, never block Razorpay - id: payment-webhook uri: lb://payment-service predicates: - - Path=/api/payments/webhook/** + - Path=/api/v1/payments/webhook/** filters: - name: RequestRateLimiter args: @@ -59,11 +56,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Order Service — strict (order spam prevention) ─────────────────── + # Order Service — strict (order spam prevention) - id: order-service uri: lb://order-service predicates: - - Path=/api/orders/** + - Path=/api/v1/orders/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -73,11 +70,25 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Inventory Service — standard ───────────────────────────────────── + # Order SSE stream — relaxed limit (long-lived connections, not transactions) + - id: order-service-sse + uri: lb://order-service + predicates: + - Path=/api/v1/orders/*/status-stream + filters: + - JwtAuthFilter + - name: RequestRateLimiter + args: + rate-limiter: "#{@standardLimiter}" + key-resolver: "#{@ipKeyResolver}" + deny-empty-key: false + empty-key-status: 429 + + # Inventory Service — standard - id: inventory-service uri: lb://inventory-service predicates: - - Path=/api/inventory/** + - Path=/api/v1/inventory/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -87,11 +98,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Payment Service — strict (financial operations) ────────────────── + # Payment Service — strict (financial operations) - id: payment-service uri: lb://payment-service predicates: - - Path=/api/payments/** + - Path=/api/v1/payments/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -101,11 +112,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Shipping Service — standard ────────────────────────────────────── + # Shipping Service — standard - id: shipping-service uri: lb://shipping-service predicates: - - Path=/api/shipping/** + - Path=/api/v1/shipping/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -115,11 +126,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Notification Service — standard ────────────────────────────────── + # Notification Service — standard - id: notification-service uri: lb://notification-service predicates: - - Path=/api/notifications/** + - Path=/api/v1/notifications/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -129,11 +140,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── User Service — standard ────────────────────────────────────────── + # User Service — standard - id: user-service uri: lb://user-service predicates: - - Path=/api/users/** + - Path=/api/v1/users/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -143,11 +154,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Product Service — relaxed (high read traffic expected) ─────────── + # Product Service — relaxed (high read traffic expected) - id: product-service uri: lb://product-service predicates: - - Path=/api/products/** + - Path=/api/v1/products/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -157,11 +168,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Cart Service — standard ────────────────────────────────────────── + # Cart Service — standard - id: cart-service uri: lb://cart-service predicates: - - Path=/api/cart/** + - Path=/api/v1/cart/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -171,11 +182,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Wishlist Service — standard ────────────────────────────────────── + # Wishlist Service — standard - id: wishlist-service uri: lb://wishlist-service predicates: - - Path=/api/wishlist/** + - Path=/api/v1/wishlist/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -185,11 +196,11 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Seller Service — standard ──────────────────────────────────────── + # Seller Service — standard - id: seller-service uri: lb://seller-service predicates: - - Path=/api/seller/** + - Path=/api/v1/seller/** filters: - JwtAuthFilter - name: RequestRateLimiter @@ -199,13 +210,16 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Seller Admin — standard ────────────────────────────────────────── + # Seller Admin — ADMIN role required - id: seller-admin uri: lb://seller-service predicates: - - Path=/api/admin/sellers/** + - Path=/api/v1/admin/sellers/** filters: - JwtAuthFilter + - name: RoleAuthorizationFilter + args: + roles: ROLE_ADMIN - name: RequestRateLimiter args: rate-limiter: "#{@standardLimiter}" @@ -213,13 +227,16 @@ spring: deny-empty-key: false empty-key-status: 429 - # ── Logging Service — standard ─────────────────────────────────────── + # Logging Service — ADMIN role required (sensitive log data) - id: logging-service uri: lb://logging-service predicates: - - Path=/api/logs/** + - Path=/api/v1/logs/** filters: - JwtAuthFilter + - name: RoleAuthorizationFilter + args: + roles: ROLE_ADMIN - name: RequestRateLimiter args: rate-limiter: "#{@standardLimiter}" @@ -227,10 +244,106 @@ spring: deny-empty-key: false empty-key-status: 429 + # OpenAPI docs proxy routes (no JWT, no rate limit — internal dev tool) + - id: order-service-docs + uri: lb://order-service + predicates: + - Path=/v3/api-docs/order-service + filters: + - RewritePath=/v3/api-docs/order-service, /v3/api-docs + + - id: inventory-service-docs + uri: lb://inventory-service + predicates: + - Path=/v3/api-docs/inventory-service + filters: + - RewritePath=/v3/api-docs/inventory-service, /v3/api-docs + + - id: payment-service-docs + uri: lb://payment-service + predicates: + - Path=/v3/api-docs/payment-service + filters: + - RewritePath=/v3/api-docs/payment-service, /v3/api-docs + + - id: shipping-service-docs + uri: lb://shipping-service + predicates: + - Path=/v3/api-docs/shipping-service + filters: + - RewritePath=/v3/api-docs/shipping-service, /v3/api-docs + + - id: notification-service-docs + uri: lb://notification-service + predicates: + - Path=/v3/api-docs/notification-service + filters: + - RewritePath=/v3/api-docs/notification-service, /v3/api-docs + + - id: auth-service-docs + uri: lb://auth-service + predicates: + - Path=/v3/api-docs/auth-service + filters: + - RewritePath=/v3/api-docs/auth-service, /v3/api-docs + + - id: user-service-docs + uri: lb://user-service + predicates: + - Path=/v3/api-docs/user-service + filters: + - RewritePath=/v3/api-docs/user-service, /v3/api-docs + + - id: product-service-docs + uri: lb://product-service + predicates: + - Path=/v3/api-docs/product-service + filters: + - RewritePath=/v3/api-docs/product-service, /v3/api-docs + + - id: cart-service-docs + uri: lb://cart-service + predicates: + - Path=/v3/api-docs/cart-service + filters: + - RewritePath=/v3/api-docs/cart-service, /v3/api-docs + + - id: wishlist-service-docs + uri: lb://wishlist-service + predicates: + - Path=/v3/api-docs/wishlist-service + filters: + - RewritePath=/v3/api-docs/wishlist-service, /v3/api-docs + + - id: seller-service-docs + uri: lb://seller-service + predicates: + - Path=/v3/api-docs/seller-service + filters: + - RewritePath=/v3/api-docs/seller-service, /v3/api-docs + + - id: logging-service-docs + uri: lb://logging-service + predicates: + - Path=/v3/api-docs/logging-service + filters: + - RewritePath=/v3/api-docs/logging-service, /v3/api-docs + + - id: analytics-service-docs + uri: lb://analytics-service + predicates: + - Path=/v3/api-docs/analytics-service + filters: + - RewritePath=/v3/api-docs/analytics-service, /v3/api-docs + app: jwt: secret: ${JWT_SECRET:22a0de5355c2b9acf34fff4392a5e9439f59d26f1f0fba6012629bc0290a576c} + # Kafka — used by LogPublisher for structured access logging + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9095} + eureka: client: service-url: @@ -238,10 +351,42 @@ eureka: instance: prefer-ip-address: true +# OpenAPI aggregation — Swagger UI at http://localhost:8080/swagger-ui.html +# Aggregates API docs from all downstream services into one UI +springdoc: + swagger-ui: + path: /swagger-ui.html + urls: + - name: order-service + url: /v3/api-docs/order-service + - name: inventory-service + url: /v3/api-docs/inventory-service + - name: payment-service + url: /v3/api-docs/payment-service + - name: shipping-service + url: /v3/api-docs/shipping-service + - name: notification-service + url: /v3/api-docs/notification-service + - name: auth-service + url: /v3/api-docs/auth-service + - name: user-service + url: /v3/api-docs/user-service + - name: product-service + url: /v3/api-docs/product-service + - name: cart-service + url: /v3/api-docs/cart-service + - name: wishlist-service + url: /v3/api-docs/wishlist-service + - name: seller-service + url: /v3/api-docs/seller-service + - name: logging-service + url: /v3/api-docs/logging-service + - name: analytics-service + url: /v3/api-docs/analytics-service + api-docs: + enabled: true + logging: level: org.springframework.cloud.gateway: DEBUG org.springframework.data.redis: WARN - - - diff --git a/auth-service/Dockerfile b/auth-service/Dockerfile new file mode 100644 index 0000000..109c579 --- /dev/null +++ b/auth-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY auth-service/pom.xml auth-service/ +COPY auth-service/src auth-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,auth-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/auth-service/target/*.jar app.jar + +EXPOSE 8086 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/auth-service/pom.xml b/auth-service/pom.xml index 7a59187..4e9de4f 100644 --- a/auth-service/pom.xml +++ b/auth-service/pom.xml @@ -94,6 +94,18 @@ lombok true + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + + org.springframework.boot + spring-boot-starter-data-redis + diff --git a/auth-service/src/main/java/com/hacisimsek/auth/config/SecurityConfig.java b/auth-service/src/main/java/com/hacisimsek/auth/config/SecurityConfig.java index 98cb0c0..03f67a2 100644 --- a/auth-service/src/main/java/com/hacisimsek/auth/config/SecurityConfig.java +++ b/auth-service/src/main/java/com/hacisimsek/auth/config/SecurityConfig.java @@ -1,4 +1,4 @@ -package com.hacisimsek.auth.config; +package com.hacisimsek.auth.config; import com.hacisimsek.auth.security.CustomUserDetailsService; import com.hacisimsek.auth.security.JwtAuthenticationFilter; import com.hacisimsek.auth.security.oauth2.OAuth2AuthenticationFailureHandler; @@ -58,26 +58,30 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth // Public endpoints .requestMatchers( - "/api/auth/register", - "/api/auth/login", - "/api/auth/refresh", - "/api/auth/verify-email", - "/api/auth/resend-verification", - "/api/auth/forgot-password", - "/api/auth/reset-password", - "/api/auth/oauth2/**", + "/api/v1/auth/register", + "/api/v1/auth/login", + "/api/v1/auth/refresh", + "/api/v1/auth/verify-email", + "/api/v1/auth/resend-verification", + "/api/v1/auth/forgot-password", + "/api/v1/auth/reset-password", + "/api/v1/auth/oauth2/**", "/oauth2/**", "/login/oauth2/**", - "/actuator/**" + "/actuator/**", + // Swagger UI + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**" ).permitAll() // Everything else requires authentication .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(endpoint -> - endpoint.baseUri("/api/auth/oauth2/authorize")) + endpoint.baseUri("/api/v1/auth/oauth2/authorize")) .redirectionEndpoint(endpoint -> - endpoint.baseUri("/api/auth/oauth2/callback/*")) + endpoint.baseUri("/api/v1/auth/oauth2/callback/*")) .successHandler(oAuth2SuccessHandler) .failureHandler(oAuth2FailureHandler) ) diff --git a/auth-service/src/main/java/com/hacisimsek/auth/controller/AuthController.java b/auth-service/src/main/java/com/hacisimsek/auth/controller/AuthController.java index c88af23..7bfac8f 100644 --- a/auth-service/src/main/java/com/hacisimsek/auth/controller/AuthController.java +++ b/auth-service/src/main/java/com/hacisimsek/auth/controller/AuthController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.auth.controller; +package com.hacisimsek.auth.controller; import com.hacisimsek.auth.dto.*; import com.hacisimsek.auth.service.AuthService; @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/api/auth") +@RequestMapping("/api/v1/auth") @RequiredArgsConstructor @Slf4j public class AuthController { @@ -60,11 +60,19 @@ public ResponseEntity refreshToken(@Valid @RequestBody RefreshToke } /** - * Logout — revokes the refresh token. + * Logout — revokes the refresh token AND blacklists the access token in Redis. + * The access token is passed in the Authorization header (Bearer ). + * After this call, both tokens are immediately invalid. */ @PostMapping("/logout") - public ResponseEntity logout(@RequestBody RefreshTokenRequest request) { - return ResponseEntity.ok(authService.logout(request.getRefreshToken())); + public ResponseEntity logout( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @RequestBody RefreshTokenRequest request) { + String accessToken = null; + if (authHeader != null && authHeader.startsWith("Bearer ")) { + accessToken = authHeader.substring(7); + } + return ResponseEntity.ok(authService.logout(request.getRefreshToken(), accessToken)); } /** @@ -84,14 +92,14 @@ public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswo } /** - * Protected endpoint — get current user info from JWT. + * Protected endpoint — get current user info from JWT. * Example: used by frontend after login to show user profile. */ @GetMapping("/me") public ResponseEntity getCurrentUser() { // In a real app you'd extract the principal from SecurityContextHolder // and fetch user details. For now just a placeholder. - return ResponseEntity.ok(ApiResponse.ok("User info endpoint — implement as needed")); + return ResponseEntity.ok(ApiResponse.ok("User info endpoint — implement as needed")); } /** diff --git a/auth-service/src/main/java/com/hacisimsek/auth/security/JwtTokenProvider.java b/auth-service/src/main/java/com/hacisimsek/auth/security/JwtTokenProvider.java index 2fe9229..fad6df2 100644 --- a/auth-service/src/main/java/com/hacisimsek/auth/security/JwtTokenProvider.java +++ b/auth-service/src/main/java/com/hacisimsek/auth/security/JwtTokenProvider.java @@ -1,7 +1,6 @@ package com.hacisimsek.auth.security; import io.jsonwebtoken.*; -import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -22,15 +21,27 @@ public class JwtTokenProvider { private long accessTokenExpirationMs; private SecretKey getSigningKey() { - byte[] keyBytes = Decoders.BASE64.decode(jwtSecret); + // JWT_SECRET is stored as a hex string — matches api-gateway hex decoding + byte[] keyBytes = hexStringToByteArray(jwtSecret); return Keys.hmacShaKeyFor(keyBytes); } + private byte[] hexStringToByteArray(String hex) { + int len = hex.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + + Character.digit(hex.charAt(i + 1), 16)); + } + return data; + } + public String generateAccessToken(UUID userId, String email, String role) { Date now = new Date(); Date expiry = new Date(now.getTime() + accessTokenExpirationMs); return Jwts.builder() + .id(UUID.randomUUID().toString()) // jti claim — unique token ID for blacklisting .subject(userId.toString()) .claim("email", email) .claim("role", role) @@ -78,6 +89,16 @@ public String getRoleFromToken(String token) { return parseToken(token).get("role", String.class); } + /** Returns the jti (JWT ID) claim — used for blacklisting on logout */ + public String getJtiFromToken(String token) { + return parseToken(token).getId(); + } + + /** Returns the expiry Date — used to set Redis TTL on blacklist entry */ + public Date getExpiryFromToken(String token) { + return parseToken(token).getExpiration(); + } + public long getAccessTokenExpirationMs() { return accessTokenExpirationMs; } diff --git a/auth-service/src/main/java/com/hacisimsek/auth/service/AuthService.java b/auth-service/src/main/java/com/hacisimsek/auth/service/AuthService.java index b3bb0db..2b668a6 100644 --- a/auth-service/src/main/java/com/hacisimsek/auth/service/AuthService.java +++ b/auth-service/src/main/java/com/hacisimsek/auth/service/AuthService.java @@ -6,6 +6,7 @@ import com.hacisimsek.auth.model.User; import com.hacisimsek.auth.repository.UserRepository; import com.hacisimsek.auth.security.JwtTokenProvider; +import com.hacisimsek.auth.service.TokenBlacklistService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -32,6 +33,7 @@ public class AuthService { private final RefreshTokenService refreshTokenService; private final EmailService emailService; private final AuthenticationManager authenticationManager; + private final TokenBlacklistService tokenBlacklistService; @Value("${app.email.otp-expiry-minutes}") private int otpExpiryMinutes; @@ -144,8 +146,23 @@ public AuthResponse refreshToken(RefreshTokenRequest request) { // ── Logout ──────────────────────────────────────────────────────────────── @Transactional - public ApiResponse logout(String refreshToken) { + public ApiResponse logout(String refreshToken, String accessToken) { + // 1. Revoke the refresh token (already implemented) refreshTokenService.revokeRefreshToken(refreshToken); + + // 2. Blacklist the access token in Redis so it can't be reused + // before its natural 15-min expiry + if (accessToken != null && !accessToken.isBlank()) { + try { + String jti = jwtTokenProvider.getJtiFromToken(accessToken); + java.util.Date expiry = jwtTokenProvider.getExpiryFromToken(accessToken); + tokenBlacklistService.blacklist(jti, expiry); + log.info("Access token blacklisted on logout (jti={})", jti); + } catch (Exception e) { + // Don't fail logout if token is already expired or malformed + log.warn("Could not blacklist access token on logout: {}", e.getMessage()); + } + } return ApiResponse.ok("Logged out successfully"); } diff --git a/auth-service/src/main/java/com/hacisimsek/auth/service/TokenBlacklistService.java b/auth-service/src/main/java/com/hacisimsek/auth/service/TokenBlacklistService.java new file mode 100644 index 0000000..2ef2ca2 --- /dev/null +++ b/auth-service/src/main/java/com/hacisimsek/auth/service/TokenBlacklistService.java @@ -0,0 +1,57 @@ +package com.hacisimsek.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +/** + * Redis-backed access token blacklist. + * + * On logout, the access token's JTI (or the token itself hashed) is stored + * in Redis with a TTL matching the token's remaining lifetime. The gateway + * checks this blacklist on every authenticated request. + * + * Key pattern: "blacklist:" → "revoked" + * TTL = token expiry - now (so Redis auto-cleans expired entries) + * + * Why not store the full token? The JTI (JWT ID) claim uniquely identifies + * the token and is much smaller. We add JTI to every generated token. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TokenBlacklistService { + + private static final String BLACKLIST_PREFIX = "blacklist:"; + + private final StringRedisTemplate redisTemplate; + + /** + * Blacklist an access token by its JTI until it naturally expires. + * + * @param jti the jti claim from the JWT (unique token ID) + * @param expiresAt the token's expiry time — used to set Redis TTL + */ + public void blacklist(String jti, Date expiresAt) { + Duration ttl = Duration.between(Instant.now(), expiresAt.toInstant()); + if (ttl.isNegative() || ttl.isZero()) { + // Token is already expired — no need to blacklist + return; + } + String key = BLACKLIST_PREFIX + jti; + redisTemplate.opsForValue().set(key, "revoked", ttl); + log.debug("Blacklisted token jti={} for {}s", jti, ttl.toSeconds()); + } + + /** + * Returns true if the token identified by this JTI has been blacklisted. + */ + public boolean isBlacklisted(String jti) { + return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + jti)); + } +} diff --git a/auth-service/src/main/resources/application.yml b/auth-service/src/main/resources/application.yml index 4586821..f51a3d1 100644 --- a/auth-service/src/main/resources/application.yml +++ b/auth-service/src/main/resources/application.yml @@ -30,7 +30,7 @@ spring: scope: - email - profile - redirect-uri: "{baseUrl}/api/auth/oauth2/callback/{registrationId}" + redirect-uri: "{baseUrl}/api/v1/auth/oauth2/callback/{registrationId}" mail: host: ${MAIL_HOST:smtp.resend.com} @@ -44,6 +44,14 @@ spring: ssl: enable: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + connect-timeout: 2000 + timeout: 2000 + app: jwt: secret: ${JWT_SECRET} diff --git a/cart-service/Dockerfile b/cart-service/Dockerfile new file mode 100644 index 0000000..27afa12 --- /dev/null +++ b/cart-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY cart-service/pom.xml cart-service/ +COPY cart-service/src cart-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,cart-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/cart-service/target/*.jar app.jar + +EXPOSE 8089 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/cart-service/pom.xml b/cart-service/pom.xml index 578eec0..fabf3de 100644 --- a/cart-service/pom.xml +++ b/cart-service/pom.xml @@ -49,28 +49,9 @@ lombok true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + - - - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - ${lombok.version} - - - - - - - - diff --git a/cart-service/src/main/java/com/hacisimsek/cart/controller/CartController.java b/cart-service/src/main/java/com/hacisimsek/cart/controller/CartController.java index 506f660..8403a75 100644 --- a/cart-service/src/main/java/com/hacisimsek/cart/controller/CartController.java +++ b/cart-service/src/main/java/com/hacisimsek/cart/controller/CartController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.cart.controller; +package com.hacisimsek.cart.controller; import com.hacisimsek.cart.dto.AddToCartRequest; import com.hacisimsek.cart.dto.UpdateCartItemRequest; @@ -21,7 +21,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/cart") +@RequestMapping("/api/v1/cart") @RequiredArgsConstructor public class CartController { diff --git a/common-library/pom.xml b/common-library/pom.xml index e973e28..fd7b716 100644 --- a/common-library/pom.xml +++ b/common-library/pom.xml @@ -42,6 +42,15 @@ spring-kafka-test test + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + true + \ No newline at end of file diff --git a/common-library/src/main/java/com/hacisimsek/common/config/OpenApiConfig.java b/common-library/src/main/java/com/hacisimsek/common/config/OpenApiConfig.java new file mode 100644 index 0000000..2004868 --- /dev/null +++ b/common-library/src/main/java/com/hacisimsek/common/config/OpenApiConfig.java @@ -0,0 +1,72 @@ +package com.hacisimsek.common.config; + +import io.swagger.v3.oas.models.Components; +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.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Shared OpenAPI/Swagger configuration for all microservices. + * + * Each service automatically gets: + * - Swagger UI at /swagger-ui.html + * - OpenAPI JSON at /v3/api-docs + * - JWT Bearer authentication scheme in the UI + * + * ConditionalOnClass ensures this config only activates when + * springdoc-openapi is on the classpath (servlet-based services). + * api-gateway provides its own config (WebFlux variant). + */ +@Configuration +@ConditionalOnClass(name = "org.springdoc.core.models.GroupedOpenApi") +public class OpenApiConfig { + + @Value("${spring.application.name:microservice}") + private String applicationName; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title(formatTitle(applicationName)) + .description("REST API documentation for " + formatTitle(applicationName)) + .version("1.0.0") + .contact(new Contact() + .name("Zexxity Team") + .url("https://github.com/zexxitywave/EventDrivenMesh")) + .license(new License() + .name("MIT") + .url("https://opensource.org/licenses/MIT"))) + // Register JWT Bearer as a global security scheme + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) + .components(new Components() + .addSecuritySchemes("bearerAuth", new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("Enter the JWT token obtained from /api/auth/login"))); + } + + @Bean + public GroupedOpenApi publicApi() { + return GroupedOpenApi.builder() + .group("public") + .pathsToMatch("/api/**") + .build(); + } + + private String formatTitle(String name) { + // "order-service" -> "Order Service" + return java.util.Arrays.stream(name.split("[-_]")) + .map(w -> Character.toUpperCase(w.charAt(0)) + w.substring(1)) + .collect(java.util.stream.Collectors.joining(" ")); + } +} diff --git a/common-library/src/main/java/com/hacisimsek/common/health/KafkaConsumerHealthIndicator.java b/common-library/src/main/java/com/hacisimsek/common/health/KafkaConsumerHealthIndicator.java new file mode 100644 index 0000000..603ad26 --- /dev/null +++ b/common-library/src/main/java/com/hacisimsek/common/health/KafkaConsumerHealthIndicator.java @@ -0,0 +1,76 @@ +package com.hacisimsek.common.health; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.DescribeClusterOptions; +import org.apache.kafka.clients.admin.DescribeClusterResult; +import org.apache.kafka.common.Node; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.kafka.core.KafkaAdmin; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.concurrent.TimeUnit; + +/** + * Custom Kafka health indicator exposed at /actuator/health/kafka. + * + * Checks: + * 1. Can connect to the Kafka broker cluster + * 2. At least one broker node is available + * + * Only activates in services that have a KafkaAdmin bean configured + * (i.e. services that use Kafka). Non-Kafka services (cart, product, user) + * are unaffected via @ConditionalOnBean. + * + * The AdminClient is reused from the KafkaAdmin bean (already configured + * in each service's KafkaConfig). A 3-second timeout prevents blocking + * the health check thread on a slow broker. + */ +@Component +@ConditionalOnBean(KafkaAdmin.class) +@RequiredArgsConstructor +@Slf4j +public class KafkaConsumerHealthIndicator implements HealthIndicator { + + private static final int TIMEOUT_SECONDS = 3; + + private final KafkaAdmin kafkaAdmin; + + @Override + public Health health() { + try (AdminClient adminClient = AdminClient.create(kafkaAdmin.getConfigurationProperties())) { + + DescribeClusterOptions options = new DescribeClusterOptions() + .timeoutMs((int) TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)); + + DescribeClusterResult cluster = adminClient.describeCluster(options); + + String clusterId = cluster.clusterId().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + Collection nodes = cluster.nodes().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + if (nodes == null || nodes.isEmpty()) { + return Health.down() + .withDetail("reason", "No Kafka broker nodes available") + .build(); + } + + return Health.up() + .withDetail("clusterId", clusterId) + .withDetail("brokerCount", nodes.size()) + .withDetail("brokers", nodes.stream() + .map(n -> n.host() + ":" + n.port()) + .toList()) + .build(); + + } catch (Exception e) { + log.warn("[KafkaHealth] Kafka health check failed: {}", e.getMessage()); + return Health.down() + .withDetail("reason", "Cannot connect to Kafka: " + e.getMessage()) + .build(); + } + } +} diff --git a/common-library/src/main/java/com/hacisimsek/common/logging/LogPublisher.java b/common-library/src/main/java/com/hacisimsek/common/logging/LogPublisher.java index 0a4f4b4..cfec254 100644 --- a/common-library/src/main/java/com/hacisimsek/common/logging/LogPublisher.java +++ b/common-library/src/main/java/com/hacisimsek/common/logging/LogPublisher.java @@ -4,7 +4,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; - import java.util.Arrays; import java.util.Map; diff --git a/common-library/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-library/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..03ee747 --- /dev/null +++ b/common-library/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.hacisimsek.common.config.OpenApiConfig +com.hacisimsek.common.health.KafkaConsumerHealthIndicator diff --git a/docker-compose.yml b/docker-compose.yml index f5e53b6..989382d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,6 +116,32 @@ services: - grafana_data:/var/lib/grafana - ./monitoring/grafana/provisioning:/etc/grafana/provisioning + # ── Kafka Lag Monitoring ─────────────────────────────────────────────────── + # kafka-lag-exporter exposes consumer group lag as Prometheus metrics + kafka-lag-exporter: + image: lightbend/kafka-lag-exporter:0.8.2 + container_name: kafka-lag-exporter + restart: unless-stopped + ports: + - "8000:8000" + depends_on: + - kafka + volumes: + - ./monitoring/kafka-lag-exporter.conf:/opt/docker/conf/application.conf + + # ── Alertmanager ────────────────────────────────────────────────────────── + alertmanager: + image: prom/alertmanager:v0.27.0 + container_name: alertmanager + restart: unless-stopped + ports: + - "9093:9093" + volumes: + - ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + volumes: kafka_data: postgres_data: @@ -123,4 +149,3 @@ volumes: mongodb_data: prometheus_data: grafana_data: - diff --git a/inventory-service/Dockerfile b/inventory-service/Dockerfile new file mode 100644 index 0000000..9893d4d --- /dev/null +++ b/inventory-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY inventory-service/pom.xml inventory-service/ +COPY inventory-service/src inventory-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,inventory-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/inventory-service/target/*.jar app.jar + +EXPOSE 8082 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/inventory-service/pom.xml b/inventory-service/pom.xml index c2284c1..6c858dd 100644 --- a/inventory-service/pom.xml +++ b/inventory-service/pom.xml @@ -54,6 +54,11 @@ io.micrometer micrometer-registry-prometheus + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + diff --git a/inventory-service/src/main/java/com/hacisimsek/inventory/controller/InventoryController.java b/inventory-service/src/main/java/com/hacisimsek/inventory/controller/InventoryController.java index 1600e81..27fa25b 100644 --- a/inventory-service/src/main/java/com/hacisimsek/inventory/controller/InventoryController.java +++ b/inventory-service/src/main/java/com/hacisimsek/inventory/controller/InventoryController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.inventory.controller; +package com.hacisimsek.inventory.controller; import com.hacisimsek.inventory.dto.InventoryItemRequest; import com.hacisimsek.inventory.dto.InventoryItemResponse; @@ -22,7 +22,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/inventory") +@RequestMapping("/api/v1/inventory") @RequiredArgsConstructor public class InventoryController { @@ -62,7 +62,7 @@ public ResponseEntity updateInventoryItem( return ResponseEntity.ok(inventoryItemService.updateInventoryItem(id, request)); } - /** Restock — add units to an existing inventory record */ + /** Restock — add units to an existing inventory record */ @PostMapping("/{id}/restock") public ResponseEntity restock( @PathVariable UUID id, @@ -77,7 +77,7 @@ public ResponseEntity deleteInventoryItem(@PathVariable UUID id) { return ResponseEntity.noContent().build(); } - /** Check if a product has sufficient stock — used by order-service before placing an order */ + /** Check if a product has sufficient stock — used by order-service before placing an order */ @GetMapping("/check") public ResponseEntity checkAvailability( @RequestParam UUID productId, diff --git a/inventory-service/src/main/java/com/hacisimsek/inventory/saga/InventorySagaHandler.java b/inventory-service/src/main/java/com/hacisimsek/inventory/saga/InventorySagaHandler.java index 05321c9..cf36e71 100644 --- a/inventory-service/src/main/java/com/hacisimsek/inventory/saga/InventorySagaHandler.java +++ b/inventory-service/src/main/java/com/hacisimsek/inventory/saga/InventorySagaHandler.java @@ -1,19 +1,31 @@ package com.hacisimsek.inventory.saga; +import java.util.Map; + +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + import com.hacisimsek.common.event.order.OrderCreatedEvent; +import com.hacisimsek.common.event.payment.PaymentFailedEvent; +import com.hacisimsek.common.event.shipping.ShipmentFailedEvent; +import com.hacisimsek.common.logging.LogPublisher; import com.hacisimsek.inventory.service.InventoryService; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor @Slf4j public class InventorySagaHandler { + private static final String SERVICE_NAME = "inventory-service"; + private final InventoryService inventoryService; + private final LogPublisher logPublisher; + + // ── Forward flow: reserve stock when a new order arrives ───────────────── @KafkaListener( topics = "order-events", @@ -22,27 +34,116 @@ public class InventorySagaHandler { public void handleOrderEvents(ConsumerRecord record) { Object event = record.value(); - - log.info("========== EVENT RECEIVED =========="); - log.info("Event type: {}", event != null ? event.getClass().getName() : "null"); - log.info("Event value: {}", event); + log.debug("Received order event: {}", event != null ? event.getClass().getSimpleName() : "null"); try { if (event instanceof OrderCreatedEvent orderCreatedEvent) { + log.info("Processing OrderCreatedEvent for order: {}", orderCreatedEvent.getOrderId()); + inventoryService.reserveInventory(orderCreatedEvent); + } else { + log.warn("Unhandled event type on order-events: {}", + event != null ? event.getClass().getName() : "null"); + } + } catch (Exception e) { + log.error("Error processing order event", e); + } + } - log.info("✅ Processing OrderCreatedEvent"); - log.info("Order ID: {}", orderCreatedEvent.getOrderId()); + // ── Compensation: release stock when payment fails ──────────────────────── + // + // When payment fails the order is already FAILED in order-service, but the + // reserved stock still sits locked in inventory. Without releasing it the + // stock is permanently unavailable — this is the compensation step. - inventoryService.reserveInventory(orderCreatedEvent); + @KafkaListener( + topics = "payment-events", + groupId = "inventory-service-group", + containerFactory = "kafkaListenerContainerFactory") + public void handlePaymentEvents(ConsumerRecord record) { - } else { + Object event = record.value(); + log.debug("Received payment event: {}", event != null ? event.getClass().getSimpleName() : "null"); - log.warn("❌ Event is NOT OrderCreatedEvent. Actual type: {}", - event != null ? event.getClass().getName() : "null"); + if (event instanceof PaymentFailedEvent paymentFailedEvent) { + log.warn("Payment failed for order: {} — releasing reserved inventory. Reason: {}", + paymentFailedEvent.getOrderId(), paymentFailedEvent.getReason()); + try { + inventoryService.cancelReservation(paymentFailedEvent.getOrderId()); + logPublisher.warn(SERVICE_NAME, + paymentFailedEvent.getCorrelationId() != null + ? paymentFailedEvent.getCorrelationId().toString() : null, + "Inventory released (compensation) — payment failed for order: " + + paymentFailedEvent.getOrderId(), + Map.of( + "orderId", paymentFailedEvent.getOrderId().toString(), + "reason", paymentFailedEvent.getReason() != null + ? paymentFailedEvent.getReason() : "unknown", + "compensationAction", "STOCK_RELEASED" + )); + } catch (Exception e) { + // Log but don't rethrow — a missing reservation (e.g. already cancelled) + // must not block other messages in the partition + log.error("Failed to release inventory for order {} after payment failure: {}", + paymentFailedEvent.getOrderId(), e.getMessage()); + logPublisher.error(SERVICE_NAME, + paymentFailedEvent.getCorrelationId() != null + ? paymentFailedEvent.getCorrelationId().toString() : null, + "Compensation failed — could not release inventory for order: " + + paymentFailedEvent.getOrderId(), + e, + Map.of("orderId", paymentFailedEvent.getOrderId().toString())); + } + } + // PaymentProcessedEvent is intentionally ignored here — the stock was + // already deducted at reservation time. Shipping failure handles release below. + } + + // ── Compensation: release stock when shipment fails ─────────────────────── + // + // If shipping fails after a successful payment, the order is FAILED but + // the reserved (already deducted) stock must be put back. In a real system + // the payment would also be refunded — that is handled in payment-service. + + @KafkaListener( + topics = "shipping-events", + groupId = "inventory-service-group", + containerFactory = "kafkaListenerContainerFactory") + public void handleShippingEvents(ConsumerRecord record) { + + Object event = record.value(); + log.debug("Received shipping event: {}", event != null ? event.getClass().getSimpleName() : "null"); + + if (event instanceof ShipmentFailedEvent shipmentFailedEvent) { + log.warn("Shipment failed for order: {} — releasing reserved inventory. Reason: {}", + shipmentFailedEvent.getOrderId(), shipmentFailedEvent.getReason()); + try { + inventoryService.cancelReservation(shipmentFailedEvent.getOrderId()); + + logPublisher.warn(SERVICE_NAME, + shipmentFailedEvent.getCorrelationId() != null + ? shipmentFailedEvent.getCorrelationId().toString() : null, + "Inventory released (compensation) — shipment failed for order: " + + shipmentFailedEvent.getOrderId(), + Map.of( + "orderId", shipmentFailedEvent.getOrderId().toString(), + "reason", shipmentFailedEvent.getReason() != null + ? shipmentFailedEvent.getReason() : "unknown", + "compensationAction", "STOCK_RELEASED" + )); + } catch (Exception e) { + log.error("Failed to release inventory for order {} after shipment failure: {}", + shipmentFailedEvent.getOrderId(), e.getMessage()); + logPublisher.error(SERVICE_NAME, + shipmentFailedEvent.getCorrelationId() != null + ? shipmentFailedEvent.getCorrelationId().toString() : null, + "Compensation failed — could not release inventory for order: " + + shipmentFailedEvent.getOrderId(), + e, + Map.of("orderId", shipmentFailedEvent.getOrderId().toString())); } - } catch (Exception e) { - log.error("❌ EXCEPTION in handleOrderEvents", e); } + // ShipmentProcessedEvent is intentionally ignored — stock was already + // correctly deducted at reservation time and confirmed through payment. } -} \ No newline at end of file +} diff --git a/logging-service/Dockerfile b/logging-service/Dockerfile new file mode 100644 index 0000000..a89d292 --- /dev/null +++ b/logging-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY logging-service/pom.xml logging-service/ +COPY logging-service/src logging-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,logging-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/logging-service/target/*.jar app.jar + +EXPOSE 8092 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/logging-service/pom.xml b/logging-service/pom.xml index a284a65..f90ad65 100644 --- a/logging-service/pom.xml +++ b/logging-service/pom.xml @@ -46,28 +46,9 @@ lombok true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + - - - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - ${lombok.version} - - - - - - - - diff --git a/logging-service/src/main/java/com/hacisimsek/logging/controller/AlertController.java b/logging-service/src/main/java/com/hacisimsek/logging/controller/AlertController.java index 16cf805..b18a4c6 100644 --- a/logging-service/src/main/java/com/hacisimsek/logging/controller/AlertController.java +++ b/logging-service/src/main/java/com/hacisimsek/logging/controller/AlertController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.logging.controller; +package com.hacisimsek.logging.controller; import com.hacisimsek.logging.model.AlertRule; import com.hacisimsek.logging.service.AlertService; @@ -21,7 +21,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/logs/alerts") +@RequestMapping("/api/v1/logs/alerts") @RequiredArgsConstructor public class AlertController { diff --git a/logging-service/src/main/java/com/hacisimsek/logging/controller/LogController.java b/logging-service/src/main/java/com/hacisimsek/logging/controller/LogController.java index fee169b..97b9903 100644 --- a/logging-service/src/main/java/com/hacisimsek/logging/controller/LogController.java +++ b/logging-service/src/main/java/com/hacisimsek/logging/controller/LogController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.logging.controller; +package com.hacisimsek.logging.controller; import com.hacisimsek.logging.dto.LogSearchRequest; import com.hacisimsek.logging.dto.LogStatsResponse; @@ -18,7 +18,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/logs") +@RequestMapping("/api/v1/logs") @RequiredArgsConstructor public class LogController { @@ -50,7 +50,7 @@ public ResponseEntity> search( req.setPage(page); req.setSize(size); - // traceId search returns a list — wrap in simple response + // traceId search returns a list — wrap in simple response if (traceId != null && !traceId.isBlank()) { List entries = logService.getByTraceId(traceId); return ResponseEntity.ok(new org.springframework.data.domain.PageImpl<>(entries)); @@ -86,7 +86,7 @@ public ResponseEntity getStats( } /** - * Get recent ERROR logs — useful for a monitoring dashboard. + * Get recent ERROR logs — useful for a monitoring dashboard. * GET /api/logs/errors/recent?minutes=10 */ @GetMapping("/errors/recent") diff --git a/monitoring/alertmanager.yml b/monitoring/alertmanager.yml new file mode 100644 index 0000000..3fc8bc8 --- /dev/null +++ b/monitoring/alertmanager.yml @@ -0,0 +1,94 @@ +global: + # How long to wait before re-sending a resolved alert + resolve_timeout: 5m + # Default SMTP settings — override via env vars in production + smtp_smarthost: '${MAIL_HOST:smtp.resend.com}:${MAIL_PORT:587}' + smtp_from: 'alerts@zexxity.online' + smtp_auth_username: '${MAIL_USERNAME:resend}' + smtp_auth_password: '${MAIL_PASSWORD}' + smtp_require_tls: true + +# ── Routing tree ────────────────────────────────────────────────────────────── +route: + # Default receiver for all alerts + receiver: 'email-alerts' + + # Group alerts by alertname + service so one email covers all instances + group_by: [ 'alertname', 'job' ] + + # Wait 30s before sending the first notification (collects related alerts) + group_wait: 30s + + # Wait 5m before sending a notification for a new group + group_interval: 5m + + # Re-send if an alert is still firing after 4h + repeat_interval: 4h + + routes: + # Critical alerts (ServiceDown, CriticalHeapUsage) go to both email + Slack + - match: + severity: critical + receiver: 'critical-alerts' + continue: true # also send to default email receiver + + # Payment-specific failures get their own route (PCI compliance audit trail) + - match: + job: payment-service + receiver: 'payment-alerts' + continue: true + +# ── Inhibition rules ────────────────────────────────────────────────────────── +# Suppress warning-level alerts when a critical alert is already firing +# for the same service — avoids alert storms +inhibit_rules: + - source_match: + severity: critical + target_match: + severity: warning + equal: [ 'alertname', 'job' ] + +# ── Receivers ───────────────────────────────────────────────────────────────── +receivers: + + # Default receiver — email for all alerts + - name: 'email-alerts' + email_configs: + - to: '${ALERT_EMAIL:devteam@zexxity.online}' + send_resolved: true + headers: + Subject: '[{{ .Status | toUpper }}] {{ .CommonAnnotations.summary }}' + html: | +

{{ .CommonAnnotations.summary }}

+

{{ .CommonAnnotations.description }}

+
    + {{ range .Alerts }} +
  • {{ .Labels.job }} — {{ .Annotations.description }}
  • + {{ end }} +
+ + # Critical alerts — Slack webhook + email + - name: 'critical-alerts' + slack_configs: + - api_url: '${SLACK_WEBHOOK_URL:https://hooks.slack.com/services/placeholder}' + channel: '#alerts-critical' + send_resolved: true + title: '{{ .Status | toUpper }}: {{ .CommonAnnotations.summary }}' + text: | + *Description:* {{ .CommonAnnotations.description }} + *Services affected:* + {{ range .Alerts }}• {{ .Labels.job }} ({{ .Labels.severity }}) + {{ end }} + color: '{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}' + email_configs: + - to: '${ALERT_EMAIL:devteam@zexxity.online}' + send_resolved: true + + # Payment service alerts — separate channel for audit + - name: 'payment-alerts' + slack_configs: + - api_url: '${SLACK_WEBHOOK_URL:https://hooks.slack.com/services/placeholder}' + channel: '#alerts-payments' + send_resolved: true + title: 'Payment Alert: {{ .CommonAnnotations.summary }}' + text: '{{ .CommonAnnotations.description }}' diff --git a/monitoring/kafka-lag-exporter.conf b/monitoring/kafka-lag-exporter.conf new file mode 100644 index 0000000..1ba101a --- /dev/null +++ b/monitoring/kafka-lag-exporter.conf @@ -0,0 +1,26 @@ +kafka-lag-exporter { + # How often to poll consumer group offsets (seconds) + poll-interval = 30 seconds + + # Kafka cluster to monitor + clusters = [ + { + name = "zexxity-cluster" + bootstrap-brokers = "kafka:29092" + + # Consumer groups to monitor — all saga and analytics groups + group-whitelist = [ + "order-service-group", + "inventory-service-group", + "payment-service-group", + "shipping-service-group", + "notification-service-group", + "logging-service-group", + "analytics-service-group" + ] + } + ] + + # Prometheus metrics server + port = 8000 +} diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml index dcc80b4..0a423de 100644 --- a/monitoring/prometheus.yml +++ b/monitoring/prometheus.yml @@ -4,6 +4,11 @@ external_labels: project: zexxity-ecommerce +alerting: + alertmanagers: + - static_configs: + - targets: [ alertmanager:9093 ] + rule_files: - /etc/prometheus/alert-rules.yml @@ -13,35 +18,133 @@ scrape_configs: static_configs: - targets: [ localhost:9090 ] - # ── Order Service (port 8081) ────────────────────────────────────────────── + # ── Core saga services ───────────────────────────────────────────────────── + # host.docker.internal resolves to the host machine from inside Docker. + # Replace with actual service hostnames when running in Docker Compose or K8s. + - job_name: order-service metrics_path: /actuator/prometheus static_configs: - - targets: [ 192.168.1.4:8081 ] + - targets: [ host.docker.internal:8081 ] labels: application: order-service - # ── Inventory Service (port 8082) ────────────────────────────────────────── - job_name: inventory-service metrics_path: /actuator/prometheus static_configs: - - targets: [ 192.168.1.4:8082 ] + - targets: [ host.docker.internal:8082 ] labels: application: inventory-service - # ── Payment Service (port 8083) ──────────────────────────────────────────── - job_name: payment-service metrics_path: /actuator/prometheus static_configs: - - targets: [ 192.168.1.4:8083 ] + - targets: [ host.docker.internal:8083 ] labels: application: payment-service - # ── Shipping Service (port 8085) ─────────────────────────────────────────── + - job_name: notification-service + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8084 ] + labels: + application: notification-service + - job_name: shipping-service metrics_path: /actuator/prometheus static_configs: - - targets: [ 192.168.1.4:8085 ] + - targets: [ host.docker.internal:8085 ] labels: application: shipping-service + # ── Auth & user-facing services ──────────────────────────────────────────── + + - job_name: auth-service + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8086 ] + labels: + application: auth-service + + - job_name: user-service + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8087 ] + labels: + application: user-service + + - job_name: product-service + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8088 ] + labels: + application: product-service + + - job_name: cart-service + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8089 ] + labels: + application: cart-service + + - job_name: wishlist-service + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8090 ] + labels: + application: wishlist-service + + - job_name: seller-service + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8091 ] + labels: + application: seller-service + + # ── Observability services ───────────────────────────────────────────────── + + - job_name: logging-service + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8092 ] + labels: + application: logging-service + + - job_name: analytics-service + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8093 ] + labels: + application: analytics-service + + - job_name: api-gateway + metrics_path: /actuator/prometheus + static_configs: + - targets: [ host.docker.internal:8080 ] + labels: + application: api-gateway + + # ── Kafka consumer lag ──────────────────────────────────────────────────── + # kafka-lag-exporter exposes per consumer-group lag as Prometheus metrics. + # Key metric: kafka_consumer_group_lag{group, topic, partition} + - job_name: kafka-lag-exporter + static_configs: + - targets: [ kafka-lag-exporter:8000 ] + labels: + application: kafka-lag-exporter + + # ── Kafka consumer lag ──────────────────────────────────────────────────── + # kafka-lag-exporter exposes per consumer-group lag as Prometheus metrics. + # Key metric: kafka_consumer_group_lag{group, topic, partition} + - job_name: kafka-lag-exporter + static_configs: + - targets: [ kafka-lag-exporter:8000 ] + labels: + application: kafka-lag-exporter + + # ── Alertmanager ────────────────────────────────────────────────────────── + - job_name: alertmanager + static_configs: + - targets: [ alertmanager:9093 ] + labels: + application: alertmanager diff --git a/notification-service/Dockerfile b/notification-service/Dockerfile new file mode 100644 index 0000000..3e8d766 --- /dev/null +++ b/notification-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY notification-service/pom.xml notification-service/ +COPY notification-service/src notification-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,notification-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/notification-service/target/*.jar app.jar + +EXPOSE 8084 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/notification-service/pom.xml b/notification-service/pom.xml index 4eca899..2e54850 100644 --- a/notification-service/pom.xml +++ b/notification-service/pom.xml @@ -51,6 +51,11 @@ org.springframework.cloud spring-cloud-starter-netflix-eureka-client + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + diff --git a/notification-service/src/main/java/com/hacisimsek/notification/controller/NotificationController.java b/notification-service/src/main/java/com/hacisimsek/notification/controller/NotificationController.java index 43926ff..af7dcc1 100644 --- a/notification-service/src/main/java/com/hacisimsek/notification/controller/NotificationController.java +++ b/notification-service/src/main/java/com/hacisimsek/notification/controller/NotificationController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.notification.controller; +package com.hacisimsek.notification.controller; import com.hacisimsek.notification.model.Notification; import com.hacisimsek.notification.repository.NotificationRepository; @@ -22,7 +22,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/notifications") +@RequestMapping("/api/v1/notifications") @RequiredArgsConstructor @Slf4j public class NotificationController { @@ -80,9 +80,9 @@ public ResponseEntity> markAllAsRead( * GET /api/notifications/invoice/{orderId} * * Response: - * 200 application/pdf — invoice bytes - * 404 — no ORDER_PLACED notification found for this order - * 422 — notification exists but PDF was not generated + * 200 application/pdf — invoice bytes + * 404 — no ORDER_PLACED notification found for this order + * 422 — notification exists but PDF was not generated */ @GetMapping("/invoice/{orderId}") public ResponseEntity downloadInvoice(@PathVariable UUID orderId) { diff --git a/notification-service/src/main/java/com/hacisimsek/notification/controller/TestController.java b/notification-service/src/main/java/com/hacisimsek/notification/controller/TestController.java index 664a65c..0ecd21d 100644 --- a/notification-service/src/main/java/com/hacisimsek/notification/controller/TestController.java +++ b/notification-service/src/main/java/com/hacisimsek/notification/controller/TestController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.notification.controller; +package com.hacisimsek.notification.controller; import com.hacisimsek.notification.service.NotificationService; import lombok.RequiredArgsConstructor; @@ -9,7 +9,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/test") +@RequestMapping("/api/v1/test") @RequiredArgsConstructor public class TestController { diff --git a/order-service/Dockerfile b/order-service/Dockerfile index 7211745..da1f803 100644 --- a/order-service/Dockerfile +++ b/order-service/Dockerfile @@ -1,9 +1,29 @@ -FROM eclipse-temurin:21-jdk +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY order-service/pom.xml order-service/ +COPY order-service/src order-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,order-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine WORKDIR /app -COPY target/*.jar app.jar +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/order-service/target/*.jar app.jar EXPOSE 8081 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/order-service/pom.xml b/order-service/pom.xml index 86b679b..90df3ec 100644 --- a/order-service/pom.xml +++ b/order-service/pom.xml @@ -68,6 +68,18 @@ micrometer-registry-prometheus + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + + org.springframework.statemachine + spring-statemachine-core + 4.0.0 + @@ -76,7 +88,11 @@ org.apache.maven.plugins maven-compiler-plugin - + + 16 + 16 + +
org.springframework.boot diff --git a/order-service/src/main/java/com/hacisimsek/order/OrderServiceApplication.java b/order-service/src/main/java/com/hacisimsek/order/OrderServiceApplication.java index 71b1166..8c9bffc 100644 --- a/order-service/src/main/java/com/hacisimsek/order/OrderServiceApplication.java +++ b/order-service/src/main/java/com/hacisimsek/order/OrderServiceApplication.java @@ -8,9 +8,12 @@ import java.util.TimeZone; +import org.springframework.scheduling.annotation.EnableScheduling; + @SpringBootApplication(scanBasePackages = {"com.hacisimsek.order", "com.hacisimsek.common"}) @EnableDiscoveryClient @EnableKafka +@EnableScheduling public class OrderServiceApplication { static { diff --git a/order-service/src/main/java/com/hacisimsek/order/controller/OrderController.java b/order-service/src/main/java/com/hacisimsek/order/controller/OrderController.java index a3db04e..dd20bc4 100644 --- a/order-service/src/main/java/com/hacisimsek/order/controller/OrderController.java +++ b/order-service/src/main/java/com/hacisimsek/order/controller/OrderController.java @@ -1,41 +1,96 @@ -package com.hacisimsek.order.controller; +package com.hacisimsek.order.controller; import com.hacisimsek.order.dto.OrderRequest; import com.hacisimsek.order.dto.OrderResponse; +import com.hacisimsek.order.eventsourcing.OrderEvent; +import com.hacisimsek.order.eventsourcing.OrderEventService; import com.hacisimsek.order.service.OrderService; +import com.hacisimsek.order.sse.OrderStatusEmitter; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.List; import java.util.UUID; @RestController -@RequestMapping("/api/orders") +@RequestMapping("/api/v1/orders") @RequiredArgsConstructor +@Tag(name = "Orders", description = "Order management and real-time status tracking") public class OrderController { private final OrderService orderService; + private final OrderStatusEmitter orderStatusEmitter; + private final OrderEventService orderEventService; @PostMapping @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Create a new order", description = "Starts the order saga (inventory → payment → shipping)") public OrderResponse createOrder(@Valid @RequestBody OrderRequest orderRequest) { return orderService.createOrder(orderRequest); } @GetMapping("/{orderId}") + @Operation(summary = "Get order by ID") public OrderResponse getOrderById(@PathVariable UUID orderId) { return orderService.getOrderById(orderId); } @GetMapping + @Operation(summary = "Get all orders") public List getAllOrders() { return orderService.getAllOrders(); } @GetMapping("/customer/{customerId}") + @Operation(summary = "Get orders by customer ID") public List getOrdersByCustomerId(@PathVariable UUID customerId) { return orderService.getOrdersByCustomerId(customerId); } -} \ No newline at end of file + + /** + * Event Sourcing audit trail — full immutable history of an order. + */ + @GetMapping("/{orderId}/history") + @Operation( + summary = "Get order event history", + description = "Returns the full immutable event log for an order (Event Sourcing audit trail)" + ) + public List getOrderHistory(@PathVariable UUID orderId) { + return orderEventService.getHistory(orderId); + } + + /** + * SSE endpoint — streams real-time order status updates to the client. + * + * Usage (JavaScript): + *
+     *   const es = new EventSource('/api/orders/{orderId}/status-stream');
+     *   es.addEventListener('status-update', e => console.log(JSON.parse(e.data)));
+     *   es.addEventListener('complete', () => es.close());
+     * 
+ * + * Events emitted: + * connected — immediately on subscription + * status-update — on every saga step (INVENTORY_RESERVED, PAYMENT_COMPLETED, SHIPPED, etc.) + * complete — when the order reaches a terminal state (stream then closes) + * + * The connection is held open for up to 5 minutes. If no terminal state is + * reached by then, the client should reconnect and poll {@link #getOrderById}. + */ + @GetMapping(value = "/{orderId}/status-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation( + summary = "Stream real-time order status via SSE", + description = "Opens a Server-Sent Events stream that pushes status updates as the saga progresses" + ) + public SseEmitter streamOrderStatus( + @Parameter(description = "Order ID to subscribe to") @PathVariable UUID orderId) { + return orderStatusEmitter.subscribe(orderId); + } +} diff --git a/order-service/src/main/java/com/hacisimsek/order/dto/OrderResponse.java b/order-service/src/main/java/com/hacisimsek/order/dto/OrderResponse.java index 44208cb..264fbb2 100644 --- a/order-service/src/main/java/com/hacisimsek/order/dto/OrderResponse.java +++ b/order-service/src/main/java/com/hacisimsek/order/dto/OrderResponse.java @@ -5,7 +5,6 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; - import java.math.BigDecimal; import java.time.Instant; import java.util.List; diff --git a/order-service/src/main/java/com/hacisimsek/order/eventsourcing/OrderEvent.java b/order-service/src/main/java/com/hacisimsek/order/eventsourcing/OrderEvent.java new file mode 100644 index 0000000..80da1bc --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/eventsourcing/OrderEvent.java @@ -0,0 +1,91 @@ +package com.hacisimsek.order.eventsourcing; + +import com.hacisimsek.order.model.Order; +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Immutable event record that captures every state transition of an Order. + * + * This is the Event Sourcing append-only log. Every time an order's status + * changes, a new OrderEvent row is inserted — never updated, never deleted. + * + * The full history of an order is the ordered sequence of its OrderEvents. + * The current state can always be rebuilt by replaying them in sequence. + * + * Indexing: + * - orderId + occurredAt for chronological history queries + * - correlationId for saga-level tracing across services + */ +@Entity +@Table(name = "order_events", + indexes = { + @Index(name = "idx_order_events_order_id", columnList = "orderId, occurredAt"), + @Index(name = "idx_order_events_correlation", columnList = "correlationId") + }) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderEvent { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + /** The order this event belongs to */ + @Column(nullable = false) + private UUID orderId; + + /** Correlation ID that ties this event to the saga and to gateway logs */ + private UUID correlationId; + + /** The type of event — maps to OrderStatus transitions */ + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EventType eventType; + + /** The new status after this event */ + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Order.OrderStatus newStatus; + + /** The previous status before this event (null for ORDER_CREATED) */ + @Enumerated(EnumType.STRING) + private Order.OrderStatus previousStatus; + + /** Who or what triggered this event (e.g. "inventory-service", "payment-service") */ + @Column(length = 100) + private String triggeredBy; + + /** Optional reason/detail (e.g. failure reason, tracking number) */ + @Column(length = 500) + private String details; + + /** When this event occurred — immutable, set at insert time */ + @Column(nullable = false, updatable = false) + private Instant occurredAt; + + @PrePersist + protected void onCreate() { + this.occurredAt = Instant.now(); + } + + public enum EventType { + ORDER_CREATED, + INVENTORY_CHECKING, + INVENTORY_RESERVED, + INVENTORY_RESERVATION_FAILED, + PAYMENT_PROCESSING, + PAYMENT_COMPLETED, + PAYMENT_FAILED, + SHIPPING_PROCESSING, + ORDER_SHIPPED, + ORDER_COMPLETED, + ORDER_CANCELLED, + ORDER_FAILED + } +} diff --git a/order-service/src/main/java/com/hacisimsek/order/eventsourcing/OrderEventRepository.java b/order-service/src/main/java/com/hacisimsek/order/eventsourcing/OrderEventRepository.java new file mode 100644 index 0000000..3400279 --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/eventsourcing/OrderEventRepository.java @@ -0,0 +1,21 @@ +package com.hacisimsek.order.eventsourcing; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +/** + * Repository for the append-only order_events table. + * + * Events are always inserted, never updated or deleted. + * Queries return events in chronological order. + */ +public interface OrderEventRepository extends JpaRepository { + + /** Full audit trail for one order — ordered oldest first */ + List findByOrderIdOrderByOccurredAtAsc(UUID orderId); + + /** All events tied to a saga correlation ID — cross-service audit */ + List findByCorrelationIdOrderByOccurredAtAsc(UUID correlationId); +} diff --git a/order-service/src/main/java/com/hacisimsek/order/eventsourcing/OrderEventService.java b/order-service/src/main/java/com/hacisimsek/order/eventsourcing/OrderEventService.java new file mode 100644 index 0000000..4817726 --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/eventsourcing/OrderEventService.java @@ -0,0 +1,78 @@ +package com.hacisimsek.order.eventsourcing; + +import com.hacisimsek.order.model.Order; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +/** + * Service for appending and querying order events (Event Sourcing log). + * + * Every call to {@link #append} inserts one immutable row into order_events. + * The event log is the source of truth for what happened to an order and when. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class OrderEventService { + + private final OrderEventRepository orderEventRepository; + + /** + * Append a new event to the order's event log. + * Called inside the same transaction as the Order status update. + */ + @Transactional + public OrderEvent append(UUID orderId, + UUID correlationId, + OrderEvent.EventType eventType, + Order.OrderStatus previousStatus, + Order.OrderStatus newStatus, + String triggeredBy, + String details) { + OrderEvent event = OrderEvent.builder() + .orderId(orderId) + .correlationId(correlationId) + .eventType(eventType) + .previousStatus(previousStatus) + .newStatus(newStatus) + .triggeredBy(triggeredBy) + .details(details) + .build(); + + OrderEvent saved = orderEventRepository.save(event); + log.debug("[EventStore] Appended {} for order {} ({} → {})", + eventType, orderId, previousStatus, newStatus); + return saved; + } + + /** Retrieve the full immutable event log for an order */ + @Transactional(readOnly = true) + public List getHistory(UUID orderId) { + return orderEventRepository.findByOrderIdOrderByOccurredAtAsc(orderId); + } + + /** Retrieve all events tied to a saga correlation ID */ + @Transactional(readOnly = true) + public List getByCorrelationId(UUID correlationId) { + return orderEventRepository.findByCorrelationIdOrderByOccurredAtAsc(correlationId); + } + + /** + * Rebuild current order status by replaying the event log. + * Useful for auditing or reconciling against the Order table. + */ + @Transactional(readOnly = true) + public Order.OrderStatus rebuildCurrentStatus(UUID orderId) { + List events = getHistory(orderId); + if (events.isEmpty()) { + throw new RuntimeException("No events found for order: " + orderId); + } + // The last event's newStatus is the current state + return events.get(events.size() - 1).getNewStatus(); + } +} diff --git a/order-service/src/main/java/com/hacisimsek/order/outbox/OutboxEvent.java b/order-service/src/main/java/com/hacisimsek/order/outbox/OutboxEvent.java new file mode 100644 index 0000000..f00c55b --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/outbox/OutboxEvent.java @@ -0,0 +1,79 @@ +package com.hacisimsek.order.outbox; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Outbox table entry — written in the same DB transaction as the Order. + * + * The OutboxPublisher reads unpublished rows on a fixed schedule and + * publishes them to Kafka. On success the row is marked PUBLISHED. + * On failure it stays PENDING and is retried on the next schedule tick. + * + * This guarantees at-least-once Kafka delivery even if the service + * crashes between saving the order and sending to Kafka. + */ +@Entity +@Table(name = "outbox_events", + indexes = { + @Index(name = "idx_outbox_status_created", columnList = "status, createdAt"), + @Index(name = "idx_outbox_aggregate", columnList = "aggregateId") + }) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OutboxEvent { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + /** The Kafka topic this event should be published to */ + @Column(nullable = false) + private String topic; + + /** The entity this event belongs to — used as the Kafka message key */ + @Column(nullable = false) + private UUID aggregateId; + + /** Fully-qualified Java class name of the payload (e.g. OrderCreatedEvent) */ + @Column(nullable = false) + private String eventType; + + /** JSON-serialized event payload */ + @Column(nullable = false, columnDefinition = "TEXT") + private String payload; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private Status status = Status.PENDING; + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + private Instant publishedAt; + + /** Number of failed publish attempts — for observability */ + @Builder.Default + private int retryCount = 0; + + /** Last error message from a failed publish attempt */ + @Column(length = 1000) + private String lastError; + + @PrePersist + protected void onCreate() { + this.createdAt = Instant.now(); + } + + public enum Status { + PENDING, + PUBLISHED, + FAILED // after max retries exceeded (currently 5) + } +} diff --git a/order-service/src/main/java/com/hacisimsek/order/outbox/OutboxEventRepository.java b/order-service/src/main/java/com/hacisimsek/order/outbox/OutboxEventRepository.java new file mode 100644 index 0000000..21e6a6d --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/outbox/OutboxEventRepository.java @@ -0,0 +1,21 @@ +package com.hacisimsek.order.outbox; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public interface OutboxEventRepository extends JpaRepository { + + /** Fetch all pending events ordered oldest-first (for FIFO delivery) */ + List findByStatusOrderByCreatedAtAsc(OutboxEvent.Status status); + + /** Clean up published events older than the given cutoff to keep the table small */ + @Modifying + @Query("DELETE FROM OutboxEvent e WHERE e.status = 'PUBLISHED' AND e.publishedAt < :cutoff") + void deletePublishedBefore(@Param("cutoff") Instant cutoff); +} diff --git a/order-service/src/main/java/com/hacisimsek/order/outbox/OutboxPublisher.java b/order-service/src/main/java/com/hacisimsek/order/outbox/OutboxPublisher.java new file mode 100644 index 0000000..e4ee0b7 --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/outbox/OutboxPublisher.java @@ -0,0 +1,103 @@ +package com.hacisimsek.order.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +/** + * Polls the outbox table every 5 seconds and publishes pending events to Kafka. + * + * Flow: + * 1. Read all PENDING rows (oldest first) + * 2. Deserialize payload back to the original event object + * 3. Send to the target Kafka topic synchronously (get() with 10s timeout) + * 4. On success → mark PUBLISHED + * 5. On failure → increment retryCount; after 5 failures mark FAILED + * + * A nightly cleanup job removes PUBLISHED rows older than 7 days. + * + * Why synchronous send? Because we must know whether Kafka accepted the + * message before marking it published. An async callback arriving after + * a crash would leave the row in PENDING (safe — it will be retried). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class OutboxPublisher { + + private static final int MAX_RETRIES = 5; + + private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @Scheduled(fixedDelay = 5000) // runs 5s after the previous run completes + @Transactional + public void publishPendingEvents() { + List pending = + outboxEventRepository.findByStatusOrderByCreatedAtAsc(OutboxEvent.Status.PENDING); + + if (pending.isEmpty()) return; + + log.debug("[Outbox] Processing {} pending event(s)", pending.size()); + + for (OutboxEvent event : pending) { + try { + // Deserialize the stored JSON payload back to the original event class + Class eventClass = Class.forName(event.getEventType()); + Object eventPayload = objectMapper.readValue(event.getPayload(), eventClass); + + // Synchronous send — waits for broker ACK (or throws on timeout/error) + SendResult result = kafkaTemplate + .send(event.getTopic(), event.getAggregateId().toString(), eventPayload) + .get(); + + // Mark published + event.setStatus(OutboxEvent.Status.PUBLISHED); + event.setPublishedAt(Instant.now()); + outboxEventRepository.save(event); + + log.info("[Outbox] Published {} → topic={} partition={} offset={}", + event.getEventType(), + event.getTopic(), + result.getRecordMetadata().partition(), + result.getRecordMetadata().offset()); + + } catch (Exception ex) { + int retries = event.getRetryCount() + 1; + event.setRetryCount(retries); + event.setLastError(ex.getMessage() != null + ? ex.getMessage().substring(0, Math.min(ex.getMessage().length(), 1000)) + : "unknown"); + + if (retries >= MAX_RETRIES) { + event.setStatus(OutboxEvent.Status.FAILED); + log.error("[Outbox] Event {} FAILED after {} retries. Manual intervention required. Error: {}", + event.getId(), retries, ex.getMessage()); + } else { + log.warn("[Outbox] Publish attempt {}/{} failed for event {} ({}): {}", + retries, MAX_RETRIES, event.getId(), event.getEventType(), ex.getMessage()); + } + outboxEventRepository.save(event); + } + } + } + + /** Runs nightly to clean up old published events and keep the table small */ + @Scheduled(cron = "0 0 2 * * *") // 02:00 every day + @Transactional + public void purgePublishedEvents() { + Instant cutoff = Instant.now().minus(7, ChronoUnit.DAYS); + outboxEventRepository.deletePublishedBefore(cutoff); + log.info("[Outbox] Purged published events older than 7 days"); + } +} diff --git a/order-service/src/main/java/com/hacisimsek/order/saga/OrderSagaHandler.java b/order-service/src/main/java/com/hacisimsek/order/saga/OrderSagaHandler.java index e83aedd..64e0405 100644 --- a/order-service/src/main/java/com/hacisimsek/order/saga/OrderSagaHandler.java +++ b/order-service/src/main/java/com/hacisimsek/order/saga/OrderSagaHandler.java @@ -1,113 +1,78 @@ package com.hacisimsek.order.saga; -import java.util.Map; - -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.stereotype.Component; - import com.hacisimsek.common.event.inventory.InventoryReservationFailedEvent; import com.hacisimsek.common.event.inventory.InventoryReservedEvent; import com.hacisimsek.common.event.payment.PaymentFailedEvent; import com.hacisimsek.common.event.payment.PaymentProcessedEvent; import com.hacisimsek.common.event.shipping.ShipmentFailedEvent; import com.hacisimsek.common.event.shipping.ShipmentProcessedEvent; -import com.hacisimsek.common.logging.LogPublisher; -import com.hacisimsek.order.model.Order; -import com.hacisimsek.order.service.OrderService; - +import com.hacisimsek.order.saga.orchestrator.OrderSagaOrchestrator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +/** + * Kafka listeners for the Order Saga. + * + * This class is now a thin adapter layer — it receives Kafka events and + * immediately delegates to the {@link OrderSagaOrchestrator} which owns + * all the state machine logic and compensation decisions. + * + * All business logic that was previously inline here has moved to the orchestrator. + */ @Component @RequiredArgsConstructor @Slf4j public class OrderSagaHandler { - private static final String SERVICE_NAME = "order-service"; + private final OrderSagaOrchestrator orchestrator; - private final OrderService orderService; - private final LogPublisher logPublisher; + // ── Inventory events ────────────────────────────────────────────────────── @KafkaListener(topics = "inventory-events", groupId = "order-service-group", containerFactory = "kafkaListenerContainerFactory") public void handleInventoryEvents(Object event) { - log.info("Received inventory event: {}", event.getClass().getSimpleName()); + log.debug("Received inventory event: {}", event.getClass().getSimpleName()); - if (event instanceof InventoryReservedEvent reservedEvent) { - orderService.updateOrderStatus(reservedEvent.getOrderId(), Order.OrderStatus.INVENTORY_RESERVED); - log.info("Inventory reserved for order: {}", reservedEvent.getOrderId()); - logPublisher.info(SERVICE_NAME, - reservedEvent.getCorrelationId() != null ? reservedEvent.getCorrelationId().toString() : null, - "Inventory reserved for order: " + reservedEvent.getOrderId(), - Map.of("orderId", reservedEvent.getOrderId().toString(), "status", "INVENTORY_RESERVED")); + if (event instanceof InventoryReservedEvent e) { + orchestrator.onInventoryReserved(e.getOrderId(), e.getCorrelationId()); - } else if (event instanceof InventoryReservationFailedEvent failedEvent) { - orderService.updateOrderStatus(failedEvent.getOrderId(), Order.OrderStatus.CANCELLED); - log.error("Inventory reservation failed for order: {}, reason: {}", - failedEvent.getOrderId(), failedEvent.getReason()); - logPublisher.error(SERVICE_NAME, - failedEvent.getCorrelationId() != null ? failedEvent.getCorrelationId().toString() : null, - "Inventory reservation failed — order cancelled: " + failedEvent.getOrderId(), - Map.of("orderId", failedEvent.getOrderId().toString(), - "reason", failedEvent.getReason() != null ? failedEvent.getReason() : "unknown", - "status", "CANCELLED")); + } else if (event instanceof InventoryReservationFailedEvent e) { + orchestrator.onInventoryFailed(e.getOrderId(), e.getCorrelationId(), + e.getReason() != null ? e.getReason() : "unknown"); } } + // ── Payment events ──────────────────────────────────────────────────────── + @KafkaListener(topics = "payment-events", groupId = "order-service-group", containerFactory = "kafkaListenerContainerFactory") public void handlePaymentEvents(Object event) { - log.info("Received payment event: {}", event.getClass().getSimpleName()); + log.debug("Received payment event: {}", event.getClass().getSimpleName()); - if (event instanceof PaymentProcessedEvent processedEvent) { - orderService.updateOrderStatus(processedEvent.getOrderId(), Order.OrderStatus.PAYMENT_COMPLETED); - log.info("Payment processed for order: {}", processedEvent.getOrderId()); - logPublisher.info(SERVICE_NAME, - processedEvent.getCorrelationId() != null ? processedEvent.getCorrelationId().toString() : null, - "Payment completed for order: " + processedEvent.getOrderId(), - Map.of("orderId", processedEvent.getOrderId().toString(), - "paymentId", processedEvent.getPaymentId() != null ? processedEvent.getPaymentId().toString() : "unknown", - "status", "PAYMENT_COMPLETED")); + if (event instanceof PaymentProcessedEvent e) { + orchestrator.onPaymentCompleted(e.getOrderId(), e.getCorrelationId(), e.getPaymentId()); - } else if (event instanceof PaymentFailedEvent failedEvent) { - orderService.updateOrderStatus(failedEvent.getOrderId(), Order.OrderStatus.FAILED); - log.error("Payment failed for order: {}, reason: {}", - failedEvent.getOrderId(), failedEvent.getReason()); - logPublisher.error(SERVICE_NAME, - failedEvent.getCorrelationId() != null ? failedEvent.getCorrelationId().toString() : null, - "Payment failed — order marked FAILED: " + failedEvent.getOrderId(), - Map.of("orderId", failedEvent.getOrderId().toString(), - "reason", failedEvent.getReason() != null ? failedEvent.getReason() : "unknown", - "status", "FAILED")); + } else if (event instanceof PaymentFailedEvent e) { + orchestrator.onPaymentFailed(e.getOrderId(), e.getCorrelationId(), + e.getReason() != null ? e.getReason() : "unknown"); } } + // ── Shipping events ─────────────────────────────────────────────────────── + @KafkaListener(topics = "shipping-events", groupId = "order-service-group", containerFactory = "kafkaListenerContainerFactory") public void handleShippingEvents(Object event) { - log.info("Received shipping event: {}", event.getClass().getSimpleName()); + log.debug("Received shipping event: {}", event.getClass().getSimpleName()); - if (event instanceof ShipmentProcessedEvent processedEvent) { - orderService.updateOrderStatus(processedEvent.getOrderId(), Order.OrderStatus.SHIPPED); - log.info("Order shipped: {}, tracking number: {}", - processedEvent.getOrderId(), processedEvent.getTrackingNumber()); - logPublisher.info(SERVICE_NAME, - processedEvent.getCorrelationId() != null ? processedEvent.getCorrelationId().toString() : null, - "Order shipped: " + processedEvent.getOrderId() + " | tracking: " + processedEvent.getTrackingNumber(), - Map.of("orderId", processedEvent.getOrderId().toString(), - "trackingNumber", processedEvent.getTrackingNumber() != null ? processedEvent.getTrackingNumber() : "unknown", - "status", "SHIPPED")); + if (event instanceof ShipmentProcessedEvent e) { + orchestrator.onShipmentCreated(e.getOrderId(), e.getCorrelationId(), e.getTrackingNumber()); - } else if (event instanceof ShipmentFailedEvent failedEvent) { - orderService.updateOrderStatus(failedEvent.getOrderId(), Order.OrderStatus.FAILED); - log.error("Shipping failed for order: {}, reason: {}", - failedEvent.getOrderId(), failedEvent.getReason()); - logPublisher.error(SERVICE_NAME, - failedEvent.getCorrelationId() != null ? failedEvent.getCorrelationId().toString() : null, - "Shipment failed — order marked FAILED: " + failedEvent.getOrderId(), - Map.of("orderId", failedEvent.getOrderId().toString(), - "reason", failedEvent.getReason() != null ? failedEvent.getReason() : "unknown", - "status", "FAILED")); + } else if (event instanceof ShipmentFailedEvent e) { + orchestrator.onShipmentFailed(e.getOrderId(), e.getCorrelationId(), + e.getReason() != null ? e.getReason() : "unknown"); } } -} \ No newline at end of file +} diff --git a/order-service/src/main/java/com/hacisimsek/order/saga/orchestrator/OrderSagaOrchestrator.java b/order-service/src/main/java/com/hacisimsek/order/saga/orchestrator/OrderSagaOrchestrator.java new file mode 100644 index 0000000..52d5c5d --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/saga/orchestrator/OrderSagaOrchestrator.java @@ -0,0 +1,160 @@ +package com.hacisimsek.order.saga.orchestrator; + +import com.hacisimsek.common.logging.LogPublisher; +import com.hacisimsek.order.model.Order; +import com.hacisimsek.order.service.OrderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.statemachine.StateMachine; +import org.springframework.statemachine.config.StateMachineFactory; +import org.springframework.statemachine.support.DefaultStateMachineContext; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.Map; +import java.util.UUID; + +/** + * Central Saga Orchestrator — drives the Order state machine. + * + * Instead of each service reacting to events independently (choreography), + * this orchestrator: + * 1. Maintains the authoritative state machine per order + * 2. Receives all saga outcomes (inventory/payment/shipment results) + * 3. Decides the next step and updates order status + * 4. Handles compensation centrally (e.g. release inventory on payment failure) + * + * The Kafka listeners in OrderSagaHandler delegate to this orchestrator. + * This keeps all saga logic in one place instead of scattered across handlers. + * + * State machine instances are stateless in this implementation — the current + * state is always loaded from the database (Order.status) before processing + * each event, which makes it crash-safe and idempotent. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class OrderSagaOrchestrator { + + private static final String SERVICE_NAME = "order-service"; + private static final String ORDER_ID_KEY = "orderId"; + private static final String CORRELATION_ID_KEY = "correlationId"; + + private final StateMachineFactory stateMachineFactory; + private final OrderService orderService; + private final LogPublisher logPublisher; + + // ── Public API called by Kafka listeners ────────────────────────────────── + + public void onInventoryReserved(UUID orderId, UUID correlationId) { + processEvent(orderId, correlationId, SagaEvent.INVENTORY_RESERVED, + Order.OrderStatus.INVENTORY_RESERVED, + "inventory-service", "Inventory reserved — initiating payment"); + } + + public void onInventoryFailed(UUID orderId, UUID correlationId, String reason) { + processEvent(orderId, correlationId, SagaEvent.INVENTORY_FAILED, + Order.OrderStatus.CANCELLED, + "inventory-service", "Inventory reservation failed: " + reason); + log.warn("[Orchestrator] Order {} CANCELLED — inventory failed: {}", orderId, reason); + } + + public void onPaymentCompleted(UUID orderId, UUID correlationId, UUID paymentId) { + processEvent(orderId, correlationId, SagaEvent.PAYMENT_COMPLETED, + Order.OrderStatus.PAYMENT_COMPLETED, + "payment-service", "Payment completed, paymentId=" + paymentId); + } + + public void onPaymentFailed(UUID orderId, UUID correlationId, String reason) { + processEvent(orderId, correlationId, SagaEvent.PAYMENT_FAILED, + Order.OrderStatus.FAILED, + "payment-service", "Payment failed: " + reason); + // Compensation is handled by inventory-service which listens to payment-events + // (already implemented in InventorySagaHandler) + log.warn("[Orchestrator] Order {} FAILED — payment failed: {}", orderId, reason); + } + + public void onShipmentCreated(UUID orderId, UUID correlationId, String trackingNumber) { + processEvent(orderId, correlationId, SagaEvent.SHIPMENT_CREATED, + Order.OrderStatus.SHIPPED, + "shipping-service", "Shipment created, tracking=" + trackingNumber); + } + + public void onShipmentFailed(UUID orderId, UUID correlationId, String reason) { + processEvent(orderId, correlationId, SagaEvent.SHIPMENT_FAILED, + Order.OrderStatus.FAILED, + "shipping-service", "Shipment failed: " + reason); + log.warn("[Orchestrator] Order {} FAILED — shipment failed: {}", orderId, reason); + } + + // ── Core state machine processing ───────────────────────────────────────── + + private void processEvent(UUID orderId, + UUID correlationId, + SagaEvent event, + Order.OrderStatus targetStatus, + String triggeredBy, + String details) { + try { + // Load current state from DB (crash-safe: state machine is rebuilt each time) + Order.OrderStatus currentStatus = orderService.getOrderById(orderId).getStatus(); + + // Build a state machine pre-loaded at the current state + StateMachine sm = buildStateMachine(orderId, currentStatus); + + // Send the event + Message message = MessageBuilder.withPayload(event) + .setHeader(ORDER_ID_KEY, orderId.toString()) + .setHeader(CORRELATION_ID_KEY, correlationId != null ? correlationId.toString() : "") + .build(); + + sm.sendEvent(Mono.just(message)).subscribe(); + + Order.OrderStatus newState = sm.getState().getId(); + + // Persist the new state + orderService.updateOrderStatus(orderId, newState); + + log.info("[Orchestrator] Order {} | event={} | {} → {}", + orderId, event, currentStatus, newState); + + logPublisher.info(SERVICE_NAME, + correlationId != null ? correlationId.toString() : null, + "[Orchestrator] " + details, + Map.of("orderId", orderId.toString(), + "event", event.name(), + "from", currentStatus.name(), + "to", newState.name(), + "triggeredBy", triggeredBy)); + + } catch (Exception e) { + log.error("[Orchestrator] Failed to process event {} for order {}: {}", + event, orderId, e.getMessage()); + } + } + + /** + * Build a StateMachine instance pre-restored to the given state. + * Using the factory (not a singleton) ensures each order gets isolated state. + */ + private StateMachine buildStateMachine( + UUID orderId, Order.OrderStatus currentState) throws Exception { + + StateMachine sm = + stateMachineFactory.getStateMachine(orderId.toString()); + + sm.stopReactively().block(); + + sm.getStateMachineAccessor() + .doWithAllRegions(accessor -> + accessor.resetStateMachineReactively( + new DefaultStateMachineContext<>(currentState, null, null, null) + ).block() + ); + + sm.startReactively().block(); + return sm; + } +} diff --git a/order-service/src/main/java/com/hacisimsek/order/saga/orchestrator/OrderSagaStateMachineConfig.java b/order-service/src/main/java/com/hacisimsek/order/saga/orchestrator/OrderSagaStateMachineConfig.java new file mode 100644 index 0000000..f6bcc54 --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/saga/orchestrator/OrderSagaStateMachineConfig.java @@ -0,0 +1,124 @@ +package com.hacisimsek.order.saga.orchestrator; + +import com.hacisimsek.order.model.Order; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.statemachine.config.EnableStateMachineFactory; +import org.springframework.statemachine.config.StateMachineConfigurerAdapter; +import org.springframework.statemachine.config.builders.StateMachineStateConfigurer; +import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer; + +import java.util.EnumSet; + +/** + * Spring State Machine configuration for the Order Saga Orchestrator. + * + * This replaces the scattered choreography listeners with a single, explicit + * state machine that drives the entire order lifecycle from one place. + * + * States map 1:1 with Order.OrderStatus. + * Events (SagaEvent) represent outcomes from downstream services. + * + * Transitions: + * + * PENDING ──[ORDER_PLACED]──► INVENTORY_CHECKING + * INVENTORY_CHECKING ──[INVENTORY_RESERVED]──► PAYMENT_PROCESSING + * INVENTORY_CHECKING ──[INVENTORY_FAILED]──► CANCELLED + * PAYMENT_PROCESSING ──[PAYMENT_COMPLETED]──► SHIPPING_PROCESSING + * PAYMENT_PROCESSING ──[PAYMENT_FAILED]──► FAILED (+ compensate inventory) + * SHIPPING_PROCESSING ──[SHIPMENT_CREATED]──► SHIPPED + * SHIPPING_PROCESSING ──[SHIPMENT_FAILED]──► FAILED (+ compensate inventory) + * SHIPPED ──[DELIVERY_CONFIRMED]──► COMPLETED + * + * The factory produces one StateMachine per order (keyed by orderId). + */ +@Configuration +@EnableStateMachineFactory +@Slf4j +public class OrderSagaStateMachineConfig + extends StateMachineConfigurerAdapter { + + @Override + public void configure(StateMachineStateConfigurer states) + throws Exception { + states + .withStates() + .initial(Order.OrderStatus.PENDING) + .states(EnumSet.allOf(Order.OrderStatus.class)) + .end(Order.OrderStatus.COMPLETED) + .end(Order.OrderStatus.CANCELLED) + .end(Order.OrderStatus.FAILED); + } + + @Override + public void configure(StateMachineTransitionConfigurer transitions) + throws Exception { + transitions + // Order placed → start inventory check + .withExternal() + .source(Order.OrderStatus.PENDING) + .target(Order.OrderStatus.INVENTORY_CHECKING) + .event(SagaEvent.ORDER_PLACED) + .and() + + // Inventory reserved → initiate payment + .withExternal() + .source(Order.OrderStatus.INVENTORY_CHECKING) + .target(Order.OrderStatus.INVENTORY_RESERVED) + .event(SagaEvent.INVENTORY_RESERVED) + .and() + + .withExternal() + .source(Order.OrderStatus.INVENTORY_RESERVED) + .target(Order.OrderStatus.PAYMENT_PROCESSING) + .event(SagaEvent.INVENTORY_RESERVED) + .and() + + // Inventory failed → cancel order (no compensation needed) + .withExternal() + .source(Order.OrderStatus.INVENTORY_CHECKING) + .target(Order.OrderStatus.CANCELLED) + .event(SagaEvent.INVENTORY_FAILED) + .and() + + // Payment completed → initiate shipping + .withExternal() + .source(Order.OrderStatus.PAYMENT_PROCESSING) + .target(Order.OrderStatus.PAYMENT_COMPLETED) + .event(SagaEvent.PAYMENT_COMPLETED) + .and() + + .withExternal() + .source(Order.OrderStatus.PAYMENT_COMPLETED) + .target(Order.OrderStatus.SHIPPING_PROCESSING) + .event(SagaEvent.PAYMENT_COMPLETED) + .and() + + // Payment failed → fail order + compensate inventory + .withExternal() + .source(Order.OrderStatus.PAYMENT_PROCESSING) + .target(Order.OrderStatus.FAILED) + .event(SagaEvent.PAYMENT_FAILED) + .and() + + // Shipment created → order shipped + .withExternal() + .source(Order.OrderStatus.SHIPPING_PROCESSING) + .target(Order.OrderStatus.SHIPPED) + .event(SagaEvent.SHIPMENT_CREATED) + .and() + + // Shipment failed → fail order + compensate inventory + .withExternal() + .source(Order.OrderStatus.SHIPPING_PROCESSING) + .target(Order.OrderStatus.FAILED) + .event(SagaEvent.SHIPMENT_FAILED) + .and() + + // Delivery confirmed → complete + .withExternal() + .source(Order.OrderStatus.SHIPPED) + .target(Order.OrderStatus.COMPLETED) + .event(SagaEvent.DELIVERY_CONFIRMED); + } +} diff --git a/order-service/src/main/java/com/hacisimsek/order/saga/orchestrator/SagaEvent.java b/order-service/src/main/java/com/hacisimsek/order/saga/orchestrator/SagaEvent.java new file mode 100644 index 0000000..8d526c6 --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/saga/orchestrator/SagaEvent.java @@ -0,0 +1,16 @@ +package com.hacisimsek.order.saga.orchestrator; + +/** + * Events that drive the Order Saga state machine. + * Published by downstream services and received via Kafka listeners. + */ +public enum SagaEvent { + ORDER_PLACED, + INVENTORY_RESERVED, + INVENTORY_FAILED, + PAYMENT_COMPLETED, + PAYMENT_FAILED, + SHIPMENT_CREATED, + SHIPMENT_FAILED, + DELIVERY_CONFIRMED +} diff --git a/order-service/src/main/java/com/hacisimsek/order/service/impl/OrderServiceImpl.java b/order-service/src/main/java/com/hacisimsek/order/service/impl/OrderServiceImpl.java index a57fab8..6493bb1 100644 --- a/order-service/src/main/java/com/hacisimsek/order/service/impl/OrderServiceImpl.java +++ b/order-service/src/main/java/com/hacisimsek/order/service/impl/OrderServiceImpl.java @@ -1,5 +1,7 @@ package com.hacisimsek.order.service.impl; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.hacisimsek.common.dto.OrderItemDto; import com.hacisimsek.common.event.order.OrderCreatedEvent; import com.hacisimsek.order.dto.OrderItemResponse; @@ -7,12 +9,13 @@ import com.hacisimsek.order.dto.OrderResponse; import com.hacisimsek.order.model.Order; import com.hacisimsek.order.model.OrderItem; +import com.hacisimsek.order.outbox.OutboxEvent; +import com.hacisimsek.order.outbox.OutboxEventRepository; import com.hacisimsek.order.repository.OrderRepository; import com.hacisimsek.order.service.OrderService; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; -import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,19 +24,32 @@ import java.util.UUID; import java.util.stream.Collectors; +import com.hacisimsek.order.eventsourcing.OrderEvent; +import com.hacisimsek.order.eventsourcing.OrderEventService; +import com.hacisimsek.order.sse.OrderStatusEmitter; + @Service @Slf4j public class OrderServiceImpl implements OrderService { private final OrderRepository orderRepository; - private final KafkaTemplate kafkaTemplate; + private final OutboxEventRepository outboxEventRepository; + private final ObjectMapper objectMapper; + private final OrderStatusEmitter orderStatusEmitter; + private final OrderEventService orderEventService; private final Counter ordersCreatedCounter; public OrderServiceImpl(OrderRepository orderRepository, - KafkaTemplate kafkaTemplate, + OutboxEventRepository outboxEventRepository, + ObjectMapper objectMapper, + OrderStatusEmitter orderStatusEmitter, + OrderEventService orderEventService, MeterRegistry meterRegistry) { this.orderRepository = orderRepository; - this.kafkaTemplate = kafkaTemplate; + this.outboxEventRepository = outboxEventRepository; + this.objectMapper = objectMapper; + this.orderStatusEmitter = orderStatusEmitter; + this.orderEventService = orderEventService; this.ordersCreatedCounter = Counter.builder("zexxity.orders.created") .description("Total number of orders successfully created") .register(meterRegistry); @@ -42,7 +58,7 @@ public OrderServiceImpl(OrderRepository orderRepository, @Override @Transactional public OrderResponse createOrder(OrderRequest orderRequest) { - // Convert order items from request + // ── 1. Build and save the Order ────────────────────────────────────── List orderItems = orderRequest.getItems().stream() .map(item -> OrderItem.builder() .productId(item.getProductId()) @@ -52,12 +68,10 @@ public OrderResponse createOrder(OrderRequest orderRequest) { .build()) .collect(Collectors.toList()); - // Calculate total amount BigDecimal totalAmount = orderItems.stream() .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); - // Create and save order Order order = Order.builder() .customerId(orderRequest.getCustomerId()) .customerEmail(orderRequest.getCustomerEmail()) @@ -68,8 +82,9 @@ public OrderResponse createOrder(OrderRequest orderRequest) { Order savedOrder = orderRepository.save(order); - // Start the saga by sending OrderCreatedEvent + // ── 2. Build the Kafka event ────────────────────────────────────────── UUID correlationId = UUID.randomUUID(); + List itemDtos = savedOrder.getItems().stream() .map(item -> new OrderItemDto( item.getProductId(), @@ -87,22 +102,51 @@ public OrderResponse createOrder(OrderRequest orderRequest) { savedOrder.getTotalAmount() ); - log.info("Sending OrderCreatedEvent for order {}", savedOrder.getId()); - - // Increment Prometheus counter + // ── 3. Write to the Outbox in the SAME transaction ─────────────────── + // + // By writing the OutboxEvent inside the same @Transactional method, + // both the Order row and the OutboxEvent row are committed atomically. + // If Kafka is unavailable, the OutboxPublisher scheduler will pick up + // and publish the pending row on the next tick (every 5 seconds). + // This eliminates the "dual-write" race condition in the original code. + try { + OutboxEvent outboxEntry = OutboxEvent.builder() + .topic("order-events") + .aggregateId(savedOrder.getId()) + .eventType(OrderCreatedEvent.class.getName()) + .payload(objectMapper.writeValueAsString(event)) + .status(OutboxEvent.Status.PENDING) + .build(); + + outboxEventRepository.save(outboxEntry); + log.info("Order {} saved with outbox entry (correlationId={})", + savedOrder.getId(), correlationId); + } catch (JsonProcessingException ex) { + // This would be a programming error (unparseable event) — rethrow + throw new IllegalStateException("Failed to serialize OrderCreatedEvent for outbox", ex); + } + + // ── 4. Update status and metrics ───────────────────────────────────── ordersCreatedCounter.increment(); - - // Update order status to indicate saga started savedOrder.setStatus(Order.OrderStatus.INVENTORY_CHECKING); orderRepository.save(savedOrder); - // Publish event to Kafka - log.info("BEFORE Kafka Send"); + // Append ORDER_CREATED event to the immutable event log + orderEventService.append( + savedOrder.getId(), correlationId, + OrderEvent.EventType.ORDER_CREATED, + null, Order.OrderStatus.PENDING, + "order-service", "Order created with " + itemDtos.size() + " item(s)"); -// Publish event to Kafka - kafkaTemplate.send("order-events", event); + // Append INVENTORY_CHECKING event + orderEventService.append( + savedOrder.getId(), correlationId, + OrderEvent.EventType.INVENTORY_CHECKING, + Order.OrderStatus.PENDING, Order.OrderStatus.INVENTORY_CHECKING, + "order-service", "Saga started — checking inventory"); - log.info("AFTER Kafka Send"); + // Push initial status to any SSE subscriber + orderStatusEmitter.push(savedOrder.getId(), Order.OrderStatus.INVENTORY_CHECKING.name(), false); return mapToOrderResponse(savedOrder); } @@ -133,9 +177,49 @@ public List getOrdersByCustomerId(UUID customerId) { public void updateOrderStatus(UUID orderId, Order.OrderStatus status) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new RuntimeException("Order not found with id: " + orderId)); + + Order.OrderStatus previousStatus = order.getStatus(); order.setStatus(status); orderRepository.save(order); log.info("Updated order {} status to {}", orderId, status); + + // Append transition event to the immutable event log + OrderEvent.EventType eventType = resolveEventType(status); + orderEventService.append( + orderId, null, + eventType, + previousStatus, status, + "saga", null); + + // Push real-time status update via SSE + boolean terminal = isTerminalStatus(status); + orderStatusEmitter.push(orderId, status.name(), terminal); + } + + private OrderEvent.EventType resolveEventType(Order.OrderStatus status) { + return switch (status) { + case INVENTORY_CHECKING -> OrderEvent.EventType.INVENTORY_CHECKING; + case INVENTORY_RESERVED -> OrderEvent.EventType.INVENTORY_RESERVED; + case PAYMENT_PROCESSING -> OrderEvent.EventType.PAYMENT_PROCESSING; + case PAYMENT_COMPLETED -> OrderEvent.EventType.PAYMENT_COMPLETED; + case SHIPPING_PROCESSING -> OrderEvent.EventType.SHIPPING_PROCESSING; + case SHIPPED -> OrderEvent.EventType.ORDER_SHIPPED; + case COMPLETED -> OrderEvent.EventType.ORDER_COMPLETED; + case CANCELLED -> OrderEvent.EventType.ORDER_CANCELLED; + case FAILED -> OrderEvent.EventType.ORDER_FAILED; + default -> OrderEvent.EventType.ORDER_CREATED; + }; + } + + /** + * Terminal statuses — the saga has reached a final state. + * After these, no further status changes will occur. + */ + private boolean isTerminalStatus(Order.OrderStatus status) { + return status == Order.OrderStatus.SHIPPED + || status == Order.OrderStatus.COMPLETED + || status == Order.OrderStatus.FAILED + || status == Order.OrderStatus.CANCELLED; } private OrderResponse mapToOrderResponse(Order order) { @@ -160,4 +244,4 @@ private OrderResponse mapToOrderResponse(Order order) { .lastModifiedAt(order.getLastModifiedAt()) .build(); } -} \ No newline at end of file +} diff --git a/order-service/src/main/java/com/hacisimsek/order/sse/OrderStatusEmitter.java b/order-service/src/main/java/com/hacisimsek/order/sse/OrderStatusEmitter.java new file mode 100644 index 0000000..f3f466e --- /dev/null +++ b/order-service/src/main/java/com/hacisimsek/order/sse/OrderStatusEmitter.java @@ -0,0 +1,121 @@ +package com.hacisimsek.order.sse; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages active SSE (Server-Sent Events) connections for order status updates. + * + * Each client subscribes to a specific orderId. When the order status changes + * (driven by saga events), the saga handler calls {@link #push} and the update + * is streamed instantly to any connected browser/client — no polling needed. + * + * Connection lifecycle: + * - Client opens GET /api/orders/{orderId}/status-stream + * - Server holds the connection open (SseEmitter with 5-min timeout) + * - On status change → push event to that orderId's emitter + * - On COMPLETED/CANCELLED/FAILED → push final event and complete the stream + * - On timeout or client disconnect → emitter is cleaned up automatically + * + * Thread safety: ConcurrentHashMap handles concurrent subscribe/push/cleanup. + */ +@Component +@Slf4j +public class OrderStatusEmitter { + + // orderId → active SseEmitter for that order + private final Map emitters = new ConcurrentHashMap<>(); + + /** SSE connection timeout — 5 minutes. Client should reconnect if needed. */ + private static final long TIMEOUT_MS = 5 * 60 * 1000L; + + /** + * Register a new SSE connection for the given orderId. + * Returns the emitter to be written directly to the HTTP response. + */ + public SseEmitter subscribe(UUID orderId) { + SseEmitter emitter = new SseEmitter(TIMEOUT_MS); + + // Clean up on completion, timeout, or error + emitter.onCompletion(() -> { + emitters.remove(orderId); + log.debug("[SSE] Connection completed for order {}", orderId); + }); + emitter.onTimeout(() -> { + emitters.remove(orderId); + log.debug("[SSE] Connection timed out for order {}", orderId); + }); + emitter.onError(ex -> { + emitters.remove(orderId); + log.debug("[SSE] Connection error for order {}: {}", orderId, ex.getMessage()); + }); + + emitters.put(orderId, emitter); + log.info("[SSE] Client subscribed to order {} status stream (active connections: {})", + orderId, emitters.size()); + + // Send an initial "connected" event so the client knows the stream is live + try { + emitter.send(SseEmitter.event() + .name("connected") + .data("{\"orderId\":\"" + orderId + "\",\"message\":\"Subscribed to order status stream\"}")); + } catch (IOException e) { + emitters.remove(orderId); + } + + return emitter; + } + + /** + * Push a status update to the client subscribed to this orderId. + * Called by {@link com.hacisimsek.order.service.impl.OrderServiceImpl} + * whenever the order status changes. + * + * @param orderId the order that changed + * @param newStatus the new status string (e.g. "PAYMENT_COMPLETED") + * @param terminal true if this is the final state (SHIPPED, COMPLETED, FAILED, CANCELLED) + */ + public void push(UUID orderId, String newStatus, boolean terminal) { + SseEmitter emitter = emitters.get(orderId); + if (emitter == null) { + // No connected client — that's fine, most users poll instead of streaming + return; + } + + String payload = String.format( + "{\"orderId\":\"%s\",\"status\":\"%s\",\"timestamp\":\"%s\"}", + orderId, newStatus, Instant.now()); + + try { + emitter.send(SseEmitter.event() + .name("status-update") + .data(payload)); + + log.info("[SSE] Pushed status {} to order {} subscriber", newStatus, orderId); + + // Complete the stream on terminal states — no more updates coming + if (terminal) { + emitter.send(SseEmitter.event() + .name("complete") + .data("{\"message\":\"Order reached terminal state: " + newStatus + "\"}")); + emitter.complete(); + emitters.remove(orderId); + } + } catch (IOException e) { + log.warn("[SSE] Failed to push to order {} subscriber — removing: {}", orderId, e.getMessage()); + emitters.remove(orderId); + } + } + + /** Returns the number of active SSE connections — useful for monitoring */ + public int activeConnections() { + return emitters.size(); + } +} diff --git a/order-service/src/main/resources/application.yml b/order-service/src/main/resources/application.yml new file mode 100644 index 0000000..7e90175 --- /dev/null +++ b/order-service/src/main/resources/application.yml @@ -0,0 +1,75 @@ +server: + port: 8081 + +spring: + application: + name: order-service + + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/order_db + username: ${DB_USER:postgres} + password: ${DB_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9095} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + properties: + spring.json.add.type.headers: true + consumer: + group-id: order-service-group + auto-offset-reset: earliest + enable-auto-commit: false + properties: + session.timeout.ms: 30000 + heartbeat.interval.ms: 10000 + max.poll.interval.ms: 300000 + listener: + ack-mode: record + +eureka: + client: + service-url: + defaultZone: http://${EUREKA_HOST:localhost}:8761/eureka/ + instance: + prefer-ip-address: true + +management: + endpoints: + web: + exposure: + include: health,info,prometheus,metrics + endpoint: + health: + show-details: always + prometheus: + enabled: true + metrics: + tags: + application: ${spring.application.name} + distribution: + percentiles-histogram: + http.server.requests: true + percentiles: + http.server.requests: 0.5, 0.95, 0.99 + +logging: + level: + com.hacisimsek.order: INFO diff --git a/payment-service/Dockerfile b/payment-service/Dockerfile new file mode 100644 index 0000000..0ead949 --- /dev/null +++ b/payment-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY payment-service/pom.xml payment-service/ +COPY payment-service/src payment-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,payment-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/payment-service/target/*.jar app.jar + +EXPOSE 8083 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/payment-service/pom.xml b/payment-service/pom.xml index d2e3be4..0f51b1f 100644 --- a/payment-service/pom.xml +++ b/payment-service/pom.xml @@ -66,6 +66,11 @@ io.micrometer micrometer-registry-prometheus + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + diff --git a/payment-service/src/main/java/com/hacisimsek/payment/controller/PaymentController.java b/payment-service/src/main/java/com/hacisimsek/payment/controller/PaymentController.java index 93b398e..5ac866c 100644 --- a/payment-service/src/main/java/com/hacisimsek/payment/controller/PaymentController.java +++ b/payment-service/src/main/java/com/hacisimsek/payment/controller/PaymentController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.payment.controller; +package com.hacisimsek.payment.controller; import com.hacisimsek.payment.dto.GatewayOrderResponse; import com.hacisimsek.payment.dto.InitiatePaymentRequest; @@ -24,7 +24,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/payments") +@RequestMapping("/api/v1/payments") @RequiredArgsConstructor @Slf4j public class PaymentController { @@ -32,7 +32,7 @@ public class PaymentController { private final PaymentService paymentService; private final RestTemplate restTemplate; - /** Initiate a payment — returns gateway order/session for frontend checkout UI. */ + /** Initiate a payment — returns gateway order/session for frontend checkout UI. */ @PostMapping("/initiate") public ResponseEntity initiatePayment( @Valid @RequestBody InitiatePaymentRequest request) { diff --git a/payment-service/src/main/java/com/hacisimsek/payment/controller/WebhookController.java b/payment-service/src/main/java/com/hacisimsek/payment/controller/WebhookController.java index 0aeb8a7..2917383 100644 --- a/payment-service/src/main/java/com/hacisimsek/payment/controller/WebhookController.java +++ b/payment-service/src/main/java/com/hacisimsek/payment/controller/WebhookController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.payment.controller; +package com.hacisimsek.payment.controller; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -23,13 +23,13 @@ /** * Receives webhook events pushed by Razorpay to your server. * - * Razorpay Dashboard → Settings → Webhooks → Add new webhook: + * Razorpay Dashboard → Settings → Webhooks → Add new webhook: * URL: https:///api/payments/webhook/razorpay * Events: payment.captured, payment.failed, refund.created * Secret: value of RAZORPAY_WEBHOOK_SECRET env var * * IMPORTANT: This endpoint is intentionally excluded from JWT auth in the - * API Gateway / Security config — Razorpay calls it directly, not the user. + * API Gateway / Security config — Razorpay calls it directly, not the user. * Security is provided solely by HMAC-SHA256 signature verification. * * Spring must receive the raw bytes (not a parsed object) so the signature @@ -37,7 +37,7 @@ * and convert to String only after verification passes. */ @RestController -@RequestMapping("/api/payments/webhook") +@RequestMapping("/api/v1/payments/webhook") @RequiredArgsConstructor @Slf4j public class WebhookController { @@ -45,7 +45,7 @@ public class WebhookController { private final PaymentService paymentService; private final ObjectMapper objectMapper; - /** All gateway adapters — used to look up the Razorpay adapter by type. */ + /** All gateway adapters — used to look up the Razorpay adapter by type. */ private final List gatewayAdapters; /** @@ -56,9 +56,9 @@ public class WebhookController { * X-Razorpay-Signature: * * Response contract: - * 200 OK → event acknowledged (Razorpay will not retry) - * 400 → signature invalid (logged, no retry by Razorpay for bad sig) - * 500 → processing error (Razorpay WILL retry — safe to throw on transient errors) + * 200 OK → event acknowledged (Razorpay will not retry) + * 400 → signature invalid (logged, no retry by Razorpay for bad sig) + * 500 → processing error (Razorpay WILL retry — safe to throw on transient errors) */ @PostMapping( value = "/razorpay", @@ -73,24 +73,24 @@ public ResponseEntity> handleRazorpayWebhook( log.info("[Webhook] Razorpay event received, bodyLength={}, signaturePresent={}", rawBody.length, signature != null); - // ── 1. Verify HMAC signature ────────────────────────────────────────── + // ── 1. Verify HMAC signature ────────────────────────────────────────── PaymentGatewayAdapter razorpayAdapter = resolveAdapter(Payment.PaymentGateway.RAZORPAY); boolean signatureValid = razorpayAdapter.verifyWebhookSignature(bodyString, signature); if (!signatureValid) { - log.error("[Webhook] Razorpay signature verification FAILED — rejecting event"); + log.error("[Webhook] Razorpay signature verification FAILED — rejecting event"); return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(Map.of("status", "error", "message", "Invalid signature")); } - // ── 2. Parse event type ─────────────────────────────────────────────── + // ── 2. Parse event type ─────────────────────────────────────────────── String eventType; try { JsonNode root = objectMapper.readTree(bodyString); eventType = root.path("event").asText(); if (eventType.isBlank()) { - log.warn("[Webhook] No 'event' field in payload — ignoring"); + log.warn("[Webhook] No 'event' field in payload — ignoring"); return ResponseEntity.ok(Map.of("status", "ignored", "reason", "missing event field")); } } catch (Exception e) { @@ -100,7 +100,7 @@ public ResponseEntity> handleRazorpayWebhook( .body(Map.of("status", "error", "message", "Invalid JSON payload")); } - // ── 3. Delegate to service ──────────────────────────────────────────── + // ── 3. Delegate to service ──────────────────────────────────────────── try { paymentService.handleWebhookEvent(eventType, bodyString); log.info("[Webhook] Event '{}' processed successfully", eventType); @@ -110,11 +110,11 @@ public ResponseEntity> handleRazorpayWebhook( log.error("[Webhook] Error processing event '{}': {}", eventType, e.getMessage(), e); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(Map.of("status", "error", "message", "Processing failed — will retry")); + .body(Map.of("status", "error", "message", "Processing failed — will retry")); } } - // ── Helper ──────────────────────────────────────────────────────────────── + // ── Helper ──────────────────────────────────────────────────────────────── private PaymentGatewayAdapter resolveAdapter(Payment.PaymentGateway gateway) { Map index = gatewayAdapters.stream() diff --git a/payment-service/src/main/resources/application.yml b/payment-service/src/main/resources/application.yml index f09ee9a..886ed71 100644 --- a/payment-service/src/main/resources/application.yml +++ b/payment-service/src/main/resources/application.yml @@ -126,3 +126,9 @@ management: #GET http://localhost:8080/api/orders/98c44782-f8ec-4ff0-bf22-e276ffb43a4e + +# +# SELECT * +# FROM transactions +# WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '5 days' +# ORDER BY created_at DESC; \ No newline at end of file diff --git a/pom.xml b/pom.xml index fccda22..5fb93a6 100644 --- a/pom.xml +++ b/pom.xml @@ -25,6 +25,7 @@ 2023.0.2 1.18.38 1.5.5.Final + 2.5.0 @@ -61,6 +62,20 @@ mapstruct ${mapstruct.version} + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.springdoc + springdoc-openapi-starter-webflux-ui + ${springdoc.version} + diff --git a/product-service/Dockerfile b/product-service/Dockerfile new file mode 100644 index 0000000..f6e6aa1 --- /dev/null +++ b/product-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY product-service/pom.xml product-service/ +COPY product-service/src product-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,product-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/product-service/target/*.jar app.jar + +EXPOSE 8088 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/product-service/pom.xml b/product-service/pom.xml index 5886223..3acc2e0 100644 --- a/product-service/pom.xml +++ b/product-service/pom.xml @@ -48,28 +48,9 @@ lombok true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + - - - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - ${lombok.version} - - - - - - - - diff --git a/product-service/src/main/java/com/hacisimsek/product/controller/CategoryController.java b/product-service/src/main/java/com/hacisimsek/product/controller/CategoryController.java index e9fea51..4c09b61 100644 --- a/product-service/src/main/java/com/hacisimsek/product/controller/CategoryController.java +++ b/product-service/src/main/java/com/hacisimsek/product/controller/CategoryController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.product.controller; +package com.hacisimsek.product.controller; import com.hacisimsek.product.dto.CategoryRequest; import com.hacisimsek.product.model.Category; @@ -20,7 +20,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/products/categories") +@RequestMapping("/api/v1/products/categories") @RequiredArgsConstructor public class CategoryController { diff --git a/product-service/src/main/java/com/hacisimsek/product/controller/ProductController.java b/product-service/src/main/java/com/hacisimsek/product/controller/ProductController.java index 97a9021..9f0b4e2 100644 --- a/product-service/src/main/java/com/hacisimsek/product/controller/ProductController.java +++ b/product-service/src/main/java/com/hacisimsek/product/controller/ProductController.java @@ -1,10 +1,12 @@ -package com.hacisimsek.product.controller; +package com.hacisimsek.product.controller; import com.hacisimsek.product.dto.ProductRequest; import com.hacisimsek.product.dto.ProductResponse; import com.hacisimsek.product.dto.ProductStatusUpdateRequest; import com.hacisimsek.product.model.ProductStatus; import com.hacisimsek.product.service.ProductService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -30,13 +32,13 @@ import java.util.UUID; @RestController -@RequestMapping("/api/products") +@RequestMapping("/api/v1/products") @RequiredArgsConstructor public class ProductController { private final ProductService productService; - // ── CRUD ────────────────────────────────────────────────────────────────── + // ── CRUD ────────────────────────────────────────────────────────────────── /** * Create a product. Seller UUID is taken from the JWT header injected by API Gateway. @@ -92,7 +94,7 @@ public ResponseEntity deleteProduct(@PathVariable UUID id) { return ResponseEntity.noContent().build(); } - // ── List / Search / Filter with Pagination ──────────────────────────────── + // ── List / Search / Filter with Pagination ──────────────────────────────── /** * Full-text search with optional filters and pagination. diff --git a/product-service/src/main/java/com/hacisimsek/product/repository/ProductRepository.java b/product-service/src/main/java/com/hacisimsek/product/repository/ProductRepository.java index 2f311f1..dbcb9b0 100644 --- a/product-service/src/main/java/com/hacisimsek/product/repository/ProductRepository.java +++ b/product-service/src/main/java/com/hacisimsek/product/repository/ProductRepository.java @@ -31,6 +31,8 @@ public interface ProductRepository extends JpaRepository { LEFT JOIN FETCH p.category WHERE p.status = :status """) +// The main reason we used JOIN FETCH in your ProductRepository is to prevent +// the N+1 query problem when your code needs Product and its Category. Page findByStatus(@Param("status") ProductStatus status, Pageable pageable); @Query(""" diff --git a/product-service/src/main/java/com/hacisimsek/product/service/ProductService.java b/product-service/src/main/java/com/hacisimsek/product/service/ProductService.java index 97cdeb2..52b6588 100644 --- a/product-service/src/main/java/com/hacisimsek/product/service/ProductService.java +++ b/product-service/src/main/java/com/hacisimsek/product/service/ProductService.java @@ -90,7 +90,8 @@ public ProductResponse updateProduct(UUID id, UUID sellerId, ProductRequest requ product.setCategory(category); } - return toResponse(productRepository.save(product)); + Product updated = productRepository.save(product); + return toResponse(updated); } @Transactional diff --git a/product-service/src/main/resources/application.yml b/product-service/src/main/resources/application.yml index 29a6abf..1cc5908 100644 --- a/product-service/src/main/resources/application.yml +++ b/product-service/src/main/resources/application.yml @@ -1,4 +1,4 @@ -server: +server: port: 8088 spring: @@ -10,11 +10,14 @@ spring: username: ${DB_USER:postgres} password: ${DB_PASSWORD:postgres} driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 jpa: hibernate: ddl-auto: update - show-sql: true + show-sql: false properties: hibernate: format_sql: true @@ -27,16 +30,18 @@ eureka: instance: prefer-ip-address: true -logging: - level: - com.hacisimsek.product: DEBUG - management: endpoints: web: exposure: - include: "*" - - - + include: health,info,prometheus,metrics + endpoint: + health: + show-details: always + metrics: + tags: + application: ${spring.application.name} +logging: + level: + com.hacisimsek.product: INFO diff --git a/seller-service/Dockerfile b/seller-service/Dockerfile new file mode 100644 index 0000000..06a44e9 --- /dev/null +++ b/seller-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY seller-service/pom.xml seller-service/ +COPY seller-service/src seller-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,seller-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/seller-service/target/*.jar app.jar + +EXPOSE 8091 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/seller-service/pom.xml b/seller-service/pom.xml index c6b4fdd..835fad7 100644 --- a/seller-service/pom.xml +++ b/seller-service/pom.xml @@ -50,28 +50,9 @@ lombok true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + - - - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - ${lombok.version} - - - - - - - - diff --git a/seller-service/src/main/java/com/hacisimsek/seller/controller/SellerAdminController.java b/seller-service/src/main/java/com/hacisimsek/seller/controller/SellerAdminController.java index 1f6daa3..b9bca55 100644 --- a/seller-service/src/main/java/com/hacisimsek/seller/controller/SellerAdminController.java +++ b/seller-service/src/main/java/com/hacisimsek/seller/controller/SellerAdminController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.seller.controller; +package com.hacisimsek.seller.controller; import com.hacisimsek.seller.dto.SellerProfileResponse; import com.hacisimsek.seller.model.VerificationStatus; @@ -21,13 +21,13 @@ * in the API Gateway or a Spring Security filter. */ @RestController -@RequestMapping("/api/admin/sellers") +@RequestMapping("/api/v1/admin/sellers") @RequiredArgsConstructor public class SellerAdminController { private final SellerService sellerService; - /** Update seller verification status — VERIFIED, REJECTED, SUSPENDED, etc. */ + /** Update seller verification status — VERIFIED, REJECTED, SUSPENDED, etc. */ @PatchMapping("/{sellerId}/verify") public ResponseEntity updateVerification( @PathVariable UUID sellerId, diff --git a/seller-service/src/main/java/com/hacisimsek/seller/controller/SellerController.java b/seller-service/src/main/java/com/hacisimsek/seller/controller/SellerController.java index cd8e0d9..d66e929 100644 --- a/seller-service/src/main/java/com/hacisimsek/seller/controller/SellerController.java +++ b/seller-service/src/main/java/com/hacisimsek/seller/controller/SellerController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.seller.controller; +package com.hacisimsek.seller.controller; import com.hacisimsek.seller.dto.OrderSummary; import com.hacisimsek.seller.dto.ProductSummary; @@ -23,7 +23,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/seller") +@RequestMapping("/api/v1/seller") @RequiredArgsConstructor public class SellerController { @@ -78,7 +78,7 @@ public ResponseEntity> getSellerOrders( } /** - * Sales analytics — total orders, revenue, average order value, product count. + * Sales analytics — total orders, revenue, average order value, product count. * Requires VERIFIED status. */ @GetMapping("/analytics") diff --git a/service-registry/Dockerfile b/service-registry/Dockerfile new file mode 100644 index 0000000..2d9343b --- /dev/null +++ b/service-registry/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY service-registry/pom.xml service-registry/ +COPY service-registry/src service-registry/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,service-registry -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/service-registry/target/*.jar app.jar + +EXPOSE 8761 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/shipping-service/Dockerfile b/shipping-service/Dockerfile new file mode 100644 index 0000000..b0c0a16 --- /dev/null +++ b/shipping-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY shipping-service/pom.xml shipping-service/ +COPY shipping-service/src shipping-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,shipping-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/shipping-service/target/*.jar app.jar + +EXPOSE 8085 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/shipping-service/pom.xml b/shipping-service/pom.xml index 09b5b3b..5db14b9 100644 --- a/shipping-service/pom.xml +++ b/shipping-service/pom.xml @@ -47,6 +47,11 @@ io.micrometer micrometer-registry-prometheus + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + diff --git a/shipping-service/src/main/java/com/hacisimsek/shipping/controller/ShippingController.java b/shipping-service/src/main/java/com/hacisimsek/shipping/controller/ShippingController.java index 9e6128c..ee1ae1a 100644 --- a/shipping-service/src/main/java/com/hacisimsek/shipping/controller/ShippingController.java +++ b/shipping-service/src/main/java/com/hacisimsek/shipping/controller/ShippingController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.shipping.controller; +package com.hacisimsek.shipping.controller; import com.hacisimsek.shipping.model.Shipment; import com.hacisimsek.shipping.service.ShippingService; @@ -9,7 +9,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/shipping") +@RequestMapping("/api/v1/shipping") @RequiredArgsConstructor public class ShippingController { diff --git a/shipping-service/src/main/java/com/hacisimsek/shipping/service/impl/ShippingServiceImpl.java b/shipping-service/src/main/java/com/hacisimsek/shipping/service/impl/ShippingServiceImpl.java index eed1925..f02de4d 100644 --- a/shipping-service/src/main/java/com/hacisimsek/shipping/service/impl/ShippingServiceImpl.java +++ b/shipping-service/src/main/java/com/hacisimsek/shipping/service/impl/ShippingServiceImpl.java @@ -71,6 +71,14 @@ public void processShipping(PaymentProcessedEvent paymentEvent) { paymentEvent.getCustomerEmail(), savedShipment.getTrackingNumber() ); +// ShipmentProcessedEvent shipmentEvent = ShipmentProcessedEvent.builder() +// .correlationId(paymentEvent.getCorrelationId()) +// .orderId(paymentEvent.getOrderId()) +// .shipmentId(savedShipment.getId()) +// .customerId(savedShipment.getCustomerId()) +// .customerEmail(paymentEvent.getCustomerEmail()) +// .trackingNumber(savedShipment.getTrackingNumber()) +// .build(); kafkaTemplate.send("shipping-events", shipmentEvent); log.info("Order shipped successfully. Order ID: {}, Tracking: {}", diff --git a/shipping-service/src/main/resources/application.properties b/shipping-service/src/main/resources/application.properties index cfa1030..e0dd941 100644 --- a/shipping-service/src/main/resources/application.properties +++ b/shipping-service/src/main/resources/application.properties @@ -1 +1,2 @@ spring.application.name=shipping-service +#spring.serverr-port:8085 \ No newline at end of file diff --git a/shipping-service/src/main/resources/application.yml b/shipping-service/src/main/resources/application.yml index ce7a2c9..4bfc289 100644 --- a/shipping-service/src/main/resources/application.yml +++ b/shipping-service/src/main/resources/application.yml @@ -35,8 +35,11 @@ spring: enable-auto-commit: false properties: session.timeout.ms: 30000 +# How long Kafka waits without heartbeats before considering consumer dead heartbeat.interval.ms: 10000 +# How often the consumer sends heartbeats max.poll.interval.ms: 300000 +# Maximum time between poll() calls while processing listener: ack-mode: record diff --git a/user-service/Dockerfile b/user-service/Dockerfile new file mode 100644 index 0000000..b40332a --- /dev/null +++ b/user-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY user-service/pom.xml user-service/ +COPY user-service/src user-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,user-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/user-service/target/*.jar app.jar + +EXPOSE 8087 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/user-service/pom.xml b/user-service/pom.xml index d60ee49..8d2f6cc 100644 --- a/user-service/pom.xml +++ b/user-service/pom.xml @@ -44,28 +44,9 @@ lombok true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + - - - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - ${lombok.version} - - - - - - - - diff --git a/user-service/src/main/java/com/hacisimsek/user/controller/UserProfileController.java b/user-service/src/main/java/com/hacisimsek/user/controller/UserProfileController.java index 0e503a7..ee9c720 100644 --- a/user-service/src/main/java/com/hacisimsek/user/controller/UserProfileController.java +++ b/user-service/src/main/java/com/hacisimsek/user/controller/UserProfileController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.user.controller; +package com.hacisimsek.user.controller; import com.hacisimsek.user.dto.AddressRequest; import com.hacisimsek.user.dto.CreateProfileRequest; @@ -26,16 +26,16 @@ import java.util.UUID; @RestController -@RequestMapping("/api/users") +@RequestMapping("/api/v1/users") @RequiredArgsConstructor public class UserProfileController { private final UserProfileService userProfileService; - // ── Profile Endpoints ───────────────────────────────────────────────────── + // ── Profile Endpoints ───────────────────────────────────────────────────── /** - * Create profile — called after registration. + * Create profile — called after registration. * The userId comes from the X-User-Id header injected by the API Gateway JWT filter. */ @PostMapping("/profile") @@ -75,7 +75,7 @@ public ResponseEntity deactivateProfile( return ResponseEntity.noContent().build(); } - // ── Preferences Endpoints ───────────────────────────────────────────────── + // ── Preferences Endpoints ───────────────────────────────────────────────── /** * Update preferences (language, currency, notification settings). @@ -87,7 +87,7 @@ public ResponseEntity updatePreferences( return ResponseEntity.ok(userProfileService.updatePreferences(userId, request)); } - // ── Address Endpoints ───────────────────────────────────────────────────── + // ── Address Endpoints ───────────────────────────────────────────────────── /** * Get all addresses for the logged-in user. diff --git a/user-service/src/main/java/com/hacisimsek/user/service/UserProfileService.java b/user-service/src/main/java/com/hacisimsek/user/service/UserProfileService.java deleted file mode 100644 index 77f392d..0000000 --- a/user-service/src/main/java/com/hacisimsek/user/service/UserProfileService.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.hacisimsek.user.service; - -import com.hacisimsek.user.dto.AddressRequest; -import com.hacisimsek.user.dto.CreateProfileRequest; -import com.hacisimsek.user.dto.PreferencesRequest; -import com.hacisimsek.user.dto.UpdateProfileRequest; -import com.hacisimsek.user.model.Address; -import com.hacisimsek.user.model.UserPreferences; -import com.hacisimsek.user.model.UserProfile; -import com.hacisimsek.user.repository.AddressRepository; -import com.hacisimsek.user.repository.UserProfileRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -@Slf4j -public class UserProfileService { - - private final UserProfileRepository profileRepository; - private final AddressRepository addressRepository; - - // ── Profile ─────────────────────────────────────────────────────────────── - - /** - * Creates a profile for a newly registered user. - * Called with the same UUID that auth-service assigned to the user. - */ - @Transactional - public UserProfile createProfile(UUID userId, CreateProfileRequest request) { - if (profileRepository.existsById(userId)) { - throw new RuntimeException("Profile already exists for user: " + userId); - } - UserProfile profile = UserProfile.builder() - .id(userId) - .fullName(request.getFullName()) - .email(request.getEmail()) - .phoneNumber(request.getPhoneNumber()) - .preferences(new UserPreferences()) - .build(); - UserProfile saved = profileRepository.save(profile); - log.info("Profile created for user: {}", userId); - return saved; - } - - @Transactional(readOnly = true) - public UserProfile getProfile(UUID userId) { - return profileRepository.findById(userId) - .orElseThrow(() -> new RuntimeException("Profile not found for user: " + userId)); - } - - @Transactional - public UserProfile updateProfile(UUID userId, UpdateProfileRequest request) { - UserProfile profile = getProfile(userId); - profile.setFullName(request.getFullName()); - if (request.getPhoneNumber() != null) { - profile.setPhoneNumber(request.getPhoneNumber()); - } - return profileRepository.save(profile); - } - - @Transactional - public void deactivateProfile(UUID userId) { - UserProfile profile = getProfile(userId); - profile.setAccountStatus(com.hacisimsek.user.model.AccountStatus.DEACTIVATED); - profileRepository.save(profile); - log.info("Profile deactivated for user: {}", userId); - } - - // ── Preferences ─────────────────────────────────────────────────────────── - - @Transactional - public UserProfile updatePreferences(UUID userId, PreferencesRequest request) { - UserProfile profile = getProfile(userId); - UserPreferences prefs = profile.getPreferences(); - if (prefs == null) prefs = new UserPreferences(); - - if (request.getLanguage() != null) prefs.setLanguage(request.getLanguage()); - if (request.getCurrency() != null) prefs.setCurrency(request.getCurrency()); - if (request.getEmailNotifications() != null) prefs.setEmailNotifications(request.getEmailNotifications()); - if (request.getSmsNotifications() != null) prefs.setSmsNotifications(request.getSmsNotifications()); - if (request.getPushNotifications() != null) prefs.setPushNotifications(request.getPushNotifications()); - - profile.setPreferences(prefs); - return profileRepository.save(profile); - } - - // ── Addresses ───────────────────────────────────────────────────────────── - - @Transactional(readOnly = true) - public List
getAddresses(UUID userId) { - // ensure profile exists - getProfile(userId); - return addressRepository.findByUserProfileId(userId); - } - - @Transactional - public Address addAddress(UUID userId, AddressRequest request) { - UserProfile profile = getProfile(userId); - - // If this address is default, clear existing default first - if (request.isDefaultAddress()) { - addressRepository.clearDefaultForUser(userId); - } - - Address address = Address.builder() - .userProfile(profile) - .label(request.getLabel()) - .recipientName(request.getRecipientName()) - .phoneNumber(request.getPhoneNumber()) - .addressLine1(request.getAddressLine1()) - .addressLine2(request.getAddressLine2()) - .city(request.getCity()) - .state(request.getState()) - .postalCode(request.getPostalCode()) - .country(request.getCountry()) - .defaultAddress(request.isDefaultAddress()) - .build(); - - return addressRepository.save(address); - } - - @Transactional - public Address updateAddress(UUID userId, UUID addressId, AddressRequest request) { - Address address = addressRepository.findByIdAndUserProfileId(addressId, userId) - .orElseThrow(() -> new RuntimeException("Address not found")); - - if (request.isDefaultAddress()) { - addressRepository.clearDefaultForUser(userId); - } - - address.setLabel(request.getLabel()); - address.setRecipientName(request.getRecipientName()); - address.setPhoneNumber(request.getPhoneNumber()); - address.setAddressLine1(request.getAddressLine1()); - address.setAddressLine2(request.getAddressLine2()); - address.setCity(request.getCity()); - address.setState(request.getState()); - address.setPostalCode(request.getPostalCode()); - address.setCountry(request.getCountry()); - address.setDefaultAddress(request.isDefaultAddress()); - - return addressRepository.save(address); - } - - @Transactional - public void deleteAddress(UUID userId, UUID addressId) { - Address address = addressRepository.findByIdAndUserProfileId(addressId, userId) - .orElseThrow(() -> new RuntimeException("Address not found")); - addressRepository.delete(address); - } - - @Transactional - public Address setDefaultAddress(UUID userId, UUID addressId) { - addressRepository.clearDefaultForUser(userId); - Address address = addressRepository.findByIdAndUserProfileId(addressId, userId) - .orElseThrow(() -> new RuntimeException("Address not found")); - address.setDefaultAddress(true); - return addressRepository.save(address); - } -} diff --git a/wishlist-service/Dockerfile b/wishlist-service/Dockerfile new file mode 100644 index 0000000..96dac86 --- /dev/null +++ b/wishlist-service/Dockerfile @@ -0,0 +1,29 @@ +# -- Stage 1: Build ---------------------------------------------------------- +FROM eclipse-temurin:21-jdk-alpine AS builder +WORKDIR /build + +COPY pom.xml . +COPY common-library/pom.xml common-library/ +COPY common-library/src common-library/src +COPY wishlist-service/pom.xml wishlist-service/ +COPY wishlist-service/src wishlist-service/src + +RUN --mount=type=cache,target=/root/.m2 ` + ./mvnw -pl common-library,wishlist-service -am clean package -DskipTests --no-transfer-progress + +# -- Stage 2: Runtime -------------------------------------------------------- +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +COPY --from=builder /build/wishlist-service/target/*.jar app.jar + +EXPOSE 8090 + +ENTRYPOINT ["java", ` + "-XX:+UseContainerSupport", ` + "-XX:MaxRAMPercentage=75.0", ` + "-Djava.security.egd=file:/dev/./urandom", ` + "-jar", "app.jar"] diff --git a/wishlist-service/pom.xml b/wishlist-service/pom.xml index c4cc48f..a1311a4 100644 --- a/wishlist-service/pom.xml +++ b/wishlist-service/pom.xml @@ -50,28 +50,9 @@ lombok true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + - - - - - org.springframework.boot - spring-boot-maven-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - ${lombok.version} - - - - - - - - diff --git a/wishlist-service/src/main/java/com/hacisimsek/wishlist/controller/WishlistController.java b/wishlist-service/src/main/java/com/hacisimsek/wishlist/controller/WishlistController.java index 9599d13..d9a57ce 100644 --- a/wishlist-service/src/main/java/com/hacisimsek/wishlist/controller/WishlistController.java +++ b/wishlist-service/src/main/java/com/hacisimsek/wishlist/controller/WishlistController.java @@ -1,4 +1,4 @@ -package com.hacisimsek.wishlist.controller; +package com.hacisimsek.wishlist.controller; import com.hacisimsek.wishlist.dto.AddToWishlistRequest; import com.hacisimsek.wishlist.dto.WishlistItemResponse; @@ -21,7 +21,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/wishlist") +@RequestMapping("/api/v1/wishlist") @RequiredArgsConstructor public class WishlistController {