From bd3e9210144ae939c0d1d5409503f338af187100 Mon Sep 17 00:00:00 2001 From: Tarun Telang Date: Fri, 24 Apr 2026 19:00:45 +0000 Subject: [PATCH] Adding code for MP Reactive Messaging 3.0.1 --- code/chapter13/README.adoc | 258 ++++++ code/chapter13/order/README.adoc | 0 code/chapter13/order/pom.xml | 82 ++ .../store/order/OrderApplication.java | 34 + .../tutorial/store/order/entity/Order.java | 44 + .../store/order/entity/OrderItem.java | 38 + .../store/order/entity/OrderStatus.java | 14 + .../tutorial/store/order/package-info.java | 14 + .../order/repository/OrderItemRepository.java | 124 +++ .../order/repository/OrderRepository.java | 109 +++ .../order/resource/OrderItemResource.java | 149 +++ .../store/order/resource/OrderResource.java | 208 +++++ .../order/service/NotificationService.java | 20 + .../store/order/service/OrderService.java | 372 ++++++++ .../service/OrderStatusEventHandler.java | 24 + .../META-INF/microprofile-config.properties | 10 + .../order/src/main/webapp/WEB-INF/beans.xml | 8 + .../order/src/main/webapp/WEB-INF/web.xml | 10 + .../order/src/main/webapp/index.html | 144 +++ .../src/main/webapp/order-status-codes.html | 75 ++ code/chapter13/payment/README.adoc | 115 +++ code/chapter13/payment/pom.xml | 82 ++ .../tutorial/PaymentRestApplication.java | 9 + .../store/payment/client/ProductClient.java | 67 ++ .../client/ProductClientWithFilters.java | 89 ++ .../client/ProductJakartaRestClient.java | 55 ++ .../ProductJakartaRestClientSimple.java | 53 ++ ...ProductServiceResponseExceptionMapper.java | 140 +++ .../store/payment/config/PaymentConfig.java | 63 ++ .../config/PaymentServiceConfigSource.java | 60 ++ .../payment/demo/ProductClientRunner.java | 72 ++ .../store/payment/dto/product/Product.java | 10 + .../tutorial/store/payment/entity/Order.java | 22 + .../store/payment/entity/OrderStatus.java | 10 + .../store/payment/entity/PaymentDetails.java | 18 + .../exception/ProductNotFoundException.java | 31 + .../ServiceUnavailableException.java | 32 + .../payment/filter/BearerTokenFilter.java | 72 ++ .../payment/filter/CorrelationIdFilter.java | 77 ++ .../payment/filter/RequestLoggingFilter.java | 100 ++ .../payment/filter/ResponseLoggingFilter.java | 136 +++ .../resource/PaymentConfigResource.java | 98 ++ .../resource/PaymentProductResource.java | 186 ++++ .../resource/ProductCatalogResource.java | 853 ++++++++++++++++++ .../FilteredProductCatalogService.java | 124 +++ .../service/PaymentRequestHandler.java | 27 + .../store/payment/service/PaymentService.java | 46 + .../service/ProductCatalogService.java | 154 ++++ .../service/ProductClientBuilderService.java | 221 +++++ .../service/ProductIntegrationService.java | 148 +++ .../store/payment/service/payment.http | 9 + .../META-INF/microprofile-config.properties | 48 + ...lipse.microprofile.config.spi.ConfigSource | 1 + .../payment/src/main/webapp/WEB-INF/beans.xml | 8 + .../payment/src/main/webapp/index.html | 140 +++ .../payment/src/main/webapp/index.jsp | 12 + .../reactive-messaging-hello/README.md | 336 +++++++ .../reactive-messaging-hello/pom.xml | 87 ++ .../ReactiveMessagingApplication.java | 13 + .../reactive/consumer/HelloConsumer.java | 75 ++ .../example/reactive/model/HelloMessage.java | 55 ++ .../reactive/publisher/HelloPublisher.java | 58 ++ .../reactive/resource/HelloResource.java | 111 +++ .../META-INF/microprofile-config.properties | 20 + .../src/main/webapp/WEB-INF/beans.xml | 8 + .../src/main/webapp/index.html | 329 +++++++ .../simple-producer-consumer/README.adoc | 92 ++ .../simple-producer-consumer/pom.xml | 70 ++ .../com/example/simple/SimpleMessageBean.java | 34 + .../META-INF/microprofile-config.properties | 2 + .../src/main/webapp/WEB-INF/beans.xml | 7 + 71 files changed, 6422 insertions(+) create mode 100644 code/chapter13/README.adoc create mode 100644 code/chapter13/order/README.adoc create mode 100644 code/chapter13/order/pom.xml create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/OrderApplication.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderItem.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderStatus.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/package-info.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderItemRepository.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderRepository.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderItemResource.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderResource.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/NotificationService.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderService.java create mode 100644 code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderStatusEventHandler.java create mode 100644 code/chapter13/order/src/main/resources/META-INF/microprofile-config.properties create mode 100644 code/chapter13/order/src/main/webapp/WEB-INF/beans.xml create mode 100644 code/chapter13/order/src/main/webapp/WEB-INF/web.xml create mode 100644 code/chapter13/order/src/main/webapp/index.html create mode 100644 code/chapter13/order/src/main/webapp/order-status-codes.html create mode 100644 code/chapter13/payment/README.adoc create mode 100644 code/chapter13/payment/pom.xml create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/PaymentRestApplication.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClient.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClientWithFilters.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductJakartaRestClient.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductJakartaRestClientSimple.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductServiceResponseExceptionMapper.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/demo/ProductClientRunner.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/dto/product/Product.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/Order.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/OrderStatus.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/ProductNotFoundException.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/ServiceUnavailableException.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/BearerTokenFilter.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/CorrelationIdFilter.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/RequestLoggingFilter.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/ResponseLoggingFilter.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentProductResource.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/ProductCatalogResource.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/FilteredProductCatalogService.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentRequestHandler.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductCatalogService.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductClientBuilderService.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductIntegrationService.java create mode 100644 code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http create mode 100644 code/chapter13/payment/src/main/resources/META-INF/microprofile-config.properties create mode 100644 code/chapter13/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource create mode 100644 code/chapter13/payment/src/main/webapp/WEB-INF/beans.xml create mode 100644 code/chapter13/payment/src/main/webapp/index.html create mode 100644 code/chapter13/payment/src/main/webapp/index.jsp create mode 100644 code/chapter13/reactive-messaging-hello/README.md create mode 100644 code/chapter13/reactive-messaging-hello/pom.xml create mode 100644 code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/ReactiveMessagingApplication.java create mode 100644 code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/consumer/HelloConsumer.java create mode 100644 code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/model/HelloMessage.java create mode 100644 code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/publisher/HelloPublisher.java create mode 100644 code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/resource/HelloResource.java create mode 100644 code/chapter13/reactive-messaging-hello/src/main/resources/META-INF/microprofile-config.properties create mode 100644 code/chapter13/reactive-messaging-hello/src/main/webapp/WEB-INF/beans.xml create mode 100644 code/chapter13/reactive-messaging-hello/src/main/webapp/index.html create mode 100644 code/chapter13/simple-producer-consumer/README.adoc create mode 100644 code/chapter13/simple-producer-consumer/pom.xml create mode 100644 code/chapter13/simple-producer-consumer/src/main/java/com/example/simple/SimpleMessageBean.java create mode 100644 code/chapter13/simple-producer-consumer/src/main/resources/META-INF/microprofile-config.properties create mode 100644 code/chapter13/simple-producer-consumer/src/main/webapp/WEB-INF/beans.xml diff --git a/code/chapter13/README.adoc b/code/chapter13/README.adoc new file mode 100644 index 00000000..2b7887ba --- /dev/null +++ b/code/chapter13/README.adoc @@ -0,0 +1,258 @@ += Design of MicroProfile E-commerce Application using Reactive Messaging +:toc: left +:icons: font +:source-highlighter: highlightjs + +This document provides a service-by-service design for using Reactive Messaging in the MicroProfile e-Commerce application. It outlines the recommended channels, event payloads, and responsibilities for each microservice, while also recommending a hybrid architecture that combines synchronous REST calls with asynchronous event-driven communication. + +== Design goals + +* Keep each service responsible for its own data and lifecycle transitions. +* Use events for long-running business workflow progression instead of tightly coupled HTTP chains. +* Keep REST for reads, administration, direct client queries, and immediate request/response interactions. +* Combine REST and Reactive Messaging in a practical hybrid architecture. +* Start simple with in-memory wiring, then switch to Kafka for multi-service deployment. + +== Recommended architecture: Hybrid REST + Reactive Messaging + +A pure event-driven design is not required for every interaction. In this application, the most practical approach is a hybrid model: + +* Use *REST with MicroProfile Rest Client* for operations that need an immediate response, such as fetching data, validating requests, or initiating checkout. +* Use *Reactive Messaging* for asynchronous workflow steps that span multiple services, such as payment authorization, inventory reservation, shipment updates, and status notifications. + +=== Use REST for + +* shopping cart and checkout initiation +* synchronous reads and lookups +* administrative CRUD operations +* validations where the caller needs an immediate result + +=== Use Reactive Messaging for + +* order lifecycle events +* payment success or failure notifications +* inventory reservation outcomes +* shipping progress updates +* audit, monitoring, and user notifications + +== Recommended channel names + +[cols="2,2,3,3", options="header"] +|=== +|Channel |Produced by |Consumed by |Purpose + +|`order-created` +|Order Service +|Payment Service +|Publish a newly persisted order that is ready for payment. + +|`payment-authorized` +|Payment Service +|Inventory Service, Order Service +|Notify that payment succeeded. + +|`payment-failed` +|Payment Service +|Order Service +|Notify that payment was rejected or failed. + +|`inventory-reserved` +|Inventory Service +|Shipment Service, Order Service +|Notify that stock has been reserved successfully. + +|`inventory-rejected` +|Inventory Service +|Order Service +|Notify that stock could not be reserved. + +|`shipment-created` +|Shipment Service +|Order Service +|Notify that shipment processing has started. + +|`shipment-dispatched` +|Shipment Service +|Order Service, User Service +|Notify that the package has left the warehouse. + +|`shipment-delivered` +|Shipment Service +|Order Service, User Service +|Notify that the order has been delivered. + +|`order-status-events` +|Order Service +|Shopping Cart Service, User Service, observability components +|Publish normalized order state changes for downstream consumers. +|=== + +== Suggested event payload classes + +Use small, explicit payload types rather than reusing full JPA-style entities everywhere. + +* `OrderCreatedEvent` +* `PaymentResultEvent` +* `InventoryReservationEvent` +* `ShipmentEvent` +* `OrderStatusChangedEvent` + +Each event should include identifiers such as `orderId`, `userId`, `timestamp`, and only the fields needed by downstream services. + +== Service-by-service design + +=== Shopping Cart Service + +The Shopping Cart service remains the user-facing checkout initiator. +Checkout begins synchronously through REST, and then transitions into an event-driven workflow once the order has been accepted. + +*Suggested bean classes:* + +* `CheckoutClient` +** Uses `RestClient` to call the Order Service synchronously at checkout time +* `OrderStatusListener` +** Consumes `order-status-events` to show current order progress in the UI + +*Recommended responsibility:* + +* Keep cart calculation and cart management synchronous. +* Use REST to submit the order when the user confirms checkout. +* Rely on downstream events only after the order has been accepted and persisted. + +=== Order Service + +The Order service should remain the system of record for order lifecycle state. + +*Suggested bean classes:* + +* `OrderCreationHandler` +** Called by the existing REST-based checkout flow +** Calls the existing `OrderService.createOrder(...)` +** Emits `OrderCreatedEvent` to `order-created` +* `OrderStatusEventHandler` +** Consumes `payment-authorized`, `payment-failed`, `inventory-reserved`, `inventory-rejected`, `shipment-created`, `shipment-dispatched`, and `shipment-delivered` +** Calls the existing `OrderService.updateOrderStatus(...)` +* `OrderStatusPublisher` +** Emits normalized updates to `order-status-events` + +*Recommended responsibility:* + +* Persist the initial order. +* Listen for downstream business outcomes. +* Update status transitions such as `CREATED`, `PAID`, `PROCESSING`, `SHIPPED`, and `DELIVERED`. + +=== Payment Service + +The Payment service should react to newly created orders and publish payment outcomes. + +*Suggested bean classes:* + +* `PaymentRequestHandler` +** `@Incoming("order-created")` +** Uses payment gateway logic from the current `PaymentService` +** Emits to either `payment-authorized` or `payment-failed` +* `PaymentAuditLogger` +** Consumes `payment-authorized` and `payment-failed` for audit and telemetry + +*Recommended responsibility:* + +* Own payment authorization and failure handling. +* Avoid direct synchronous callbacks into Order where possible. + +=== Inventory Service + +The Inventory service should reserve or reject stock after payment authorization. + +*Suggested bean classes:* + +* `InventoryReservationHandler` +** `@Incoming("payment-authorized")` +** Uses the existing `InventoryService` to validate products and update quantities +** Emits `inventory-reserved` or `inventory-rejected` +* `LowStockPublisher` +** Emits optional low-stock notifications when quantity drops below a threshold + +*Recommended responsibility:* + +* Keep inventory ownership local to the Inventory service. +* Publish stock reservation outcomes instead of requiring the Order service to poll or chain REST calls. + +=== Shipment Service + +The Shipment service should start logistics only after inventory is successfully reserved. + +*Suggested bean classes:* + +* `ShipmentRequestHandler` +** `@Incoming("inventory-reserved")` +** Creates a shipment record and emits `shipment-created` +* `ShipmentLifecyclePublisher` +** Emits `shipment-dispatched` and `shipment-delivered` + +*Recommended responsibility:* + +* Replace parts of the current direct HTTP-based coordination with event-driven shipment updates. +* Keep shipment lifecycle state local to the Shipment service. + +=== User Service + +The User service does not need to sit in the core order-processing path, but it can subscribe to status updates for communication and personalization. + +*Suggested bean classes:* + +* `UserNotificationHandler` +** `@Incoming("order-status-events")` +** Sends order progress notifications such as paid, shipped, or delivered + +*Recommended responsibility:* + +* Consume events for user-facing notifications rather than participating in transactional order workflow. + +=== Catalog Service + +The Catalog service can remain mostly REST-based for product browsing and product lookup. + +*Suggested bean classes:* + +* `ProductChangedPublisher` (optional) +** Emits product update events when price or availability metadata changes +* `InventoryProjectionHandler` (optional) +** Consumes low-stock or inventory-summary events if a denormalized read model is needed + +*Recommended responsibility:* + +* Keep product reads synchronous and simple. +* Add messaging only when product updates need to be broadcast to other services. + +== End-to-end workflow + +. Shopping Cart initiates checkout using a synchronous REST call to the Order Service +. Order Service persists the order and emits `order-created` +. Payment Service consumes `order-created` and emits `payment-authorized` or `payment-failed` +. Inventory Service consumes `payment-authorized` and emits `inventory-reserved` or `inventory-rejected` +. Shipment Service consumes `inventory-reserved` and emits `shipment-created`, then `shipment-dispatched`, then `shipment-delivered` +. Order Service consumes all result events and emits normalized `order-status-events` +. User-facing and observability components subscribe to `order-status-events` + +This approach gives the caller an immediate response at checkout time while still allowing the fulfillment workflow to proceed asynchronously across services. + +== Practical adoption plan + +=== Step 1: Introduce payment messaging + +* Order Service publishes `order-created` +* Payment Service consumes it and publishes `payment-authorized` + +=== Step 2: Add inventory reservation + +* Inventory Service consumes `payment-authorized` +* Publish `inventory-reserved` and `inventory-rejected` + +=== Step 3: Add shipment lifecycle events + +* Shipment Service consumes `inventory-reserved` +* Emit shipment lifecycle messages + +=== Step 4: Publish normalized order state + +* Order Service becomes the single place that converts downstream outcomes into official order statuses diff --git a/code/chapter13/order/README.adoc b/code/chapter13/order/README.adoc new file mode 100644 index 00000000..e69de29b diff --git a/code/chapter13/order/pom.xml b/code/chapter13/order/pom.xml new file mode 100644 index 00000000..9881e163 --- /dev/null +++ b/code/chapter13/order/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + io.microprofile.tutorial + order + 1.0-SNAPSHOT + war + + order + + + 21 + UTF-8 + 8050 + 8051 + 1.18.36 + + + + + org.eclipse.microprofile + microprofile + 7.1 + pom + provided + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + org.eclipse.microprofile.reactive.messaging + microprofile-reactive-messaging-api + 3.0.1 + provided + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + org.apache.kafka + kafka-clients + 3.7.0 + + + + + + ${project.artifactId} + + + org.apache.maven.plugins + maven-war-plugin + 3.3.2 + + false + + + + + io.openliberty.tools + liberty-maven-plugin + 3.10 + + OrderServer + + + + + diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/OrderApplication.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/OrderApplication.java new file mode 100644 index 00000000..3113aac8 --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/OrderApplication.java @@ -0,0 +1,34 @@ +package io.microprofile.tutorial.store.order; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition; +import org.eclipse.microprofile.openapi.annotations.info.Contact; +import org.eclipse.microprofile.openapi.annotations.info.Info; +import org.eclipse.microprofile.openapi.annotations.info.License; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +/** + * JAX-RS application for order management. + */ +@ApplicationPath("/api") +@OpenAPIDefinition( + info = @Info( + title = "Order API", + version = "1.0.0", + description = "API for managing orders and order items", + license = @License( + name = "Eclipse Public License 2.0", + url = "https://www.eclipse.org/legal/epl-2.0/"), + contact = @Contact( + name = "Order API Support", + email = "support@example.com")), + tags = { + @Tag(name = "Order", description = "Operations related to order management"), + @Tag(name = "OrderItem", description = "Operations related to order item management") + } +) +public class OrderApplication extends Application { + // The resources will be discovered automatically +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java new file mode 100644 index 00000000..1472ba62 --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/Order.java @@ -0,0 +1,44 @@ +package io.microprofile.tutorial.store.order.entity; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Order class for the microprofile tutorial store application. + * This class represents an order in the system with its details. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Order { + + private Long orderId; + + @NotNull(message = "User ID cannot be null") + private Long userId; + + @NotNull(message = "Total price cannot be null") + @Min(value = 0, message = "Total price must be greater than or equal to 0") + private BigDecimal totalPrice; + + @NotNull(message = "Status cannot be null") + private OrderStatus status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @Builder.Default + private List orderItems = new ArrayList<>(); +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderItem.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderItem.java new file mode 100644 index 00000000..ef849969 --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderItem.java @@ -0,0 +1,38 @@ +package io.microprofile.tutorial.store.order.entity; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * OrderItem class for the microprofile tutorial store application. + * This class represents an item within an order. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OrderItem { + + private Long orderItemId; + + @NotNull(message = "Order ID cannot be null") + private Long orderId; + + @NotNull(message = "Product ID cannot be null") + private Long productId; + + @NotNull(message = "Quantity cannot be null") + @Min(value = 1, message = "Quantity must be at least 1") + private Integer quantity; + + @NotNull(message = "Price at order cannot be null") + @Min(value = 0, message = "Price must be greater than or equal to 0") + private BigDecimal priceAtOrder; +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderStatus.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderStatus.java new file mode 100644 index 00000000..af04ec26 --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/entity/OrderStatus.java @@ -0,0 +1,14 @@ +package io.microprofile.tutorial.store.order.entity; + +/** + * OrderStatus enum for the microprofile tutorial store application. + * This enum defines the possible statuses for an order. + */ +public enum OrderStatus { + CREATED, + PAID, + PROCESSING, + SHIPPED, + DELIVERED, + CANCELLED +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/package-info.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/package-info.java new file mode 100644 index 00000000..9c72ad80 --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/package-info.java @@ -0,0 +1,14 @@ +/** + * This package contains the Order Management application for the MicroProfile tutorial store. + * + * The application demonstrates a Jakarta EE and MicroProfile-based REST service + * for managing orders and order items with CRUD operations. + * + * Main Components: + * - Entity classes: Contains order data with order_id, user_id, total_price, status + * and order item data with order_item_id, order_id, product_id, quantity, price_at_order + * - Repository: Provides in-memory data storage using HashMap + * - Service: Contains business logic and validation + * - Resource: REST endpoints with OpenAPI documentation + */ +package io.microprofile.tutorial.store.order; diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderItemRepository.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderItemRepository.java new file mode 100644 index 00000000..1aa11cf6 --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderItemRepository.java @@ -0,0 +1,124 @@ +package io.microprofile.tutorial.store.order.repository; + +import io.microprofile.tutorial.store.order.entity.OrderItem; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Simple in-memory repository for OrderItem objects. + * This class provides CRUD operations for OrderItem entities to demonstrate MicroProfile concepts. + */ +@ApplicationScoped +public class OrderItemRepository { + + private final Map orderItems = new HashMap<>(); + private long nextId = 1; + + /** + * Saves an order item to the repository. + * If the order item has no ID, a new ID is assigned. + * + * @param orderItem The order item to save + * @return The saved order item with ID assigned + */ + public OrderItem save(OrderItem orderItem) { + if (orderItem.getOrderItemId() == null) { + orderItem.setOrderItemId(nextId++); + } + orderItems.put(orderItem.getOrderItemId(), orderItem); + return orderItem; + } + + /** + * Finds an order item by ID. + * + * @param id The order item ID + * @return An Optional containing the order item if found, or empty if not found + */ + public Optional findById(Long id) { + return Optional.ofNullable(orderItems.get(id)); + } + + /** + * Finds order items by order ID. + * + * @param orderId The order ID + * @return A list of order items for the specified order + */ + public List findByOrderId(Long orderId) { + return orderItems.values().stream() + .filter(item -> item.getOrderId().equals(orderId)) + .collect(Collectors.toList()); + } + + /** + * Finds order items by product ID. + * + * @param productId The product ID + * @return A list of order items for the specified product + */ + public List findByProductId(Long productId) { + return orderItems.values().stream() + .filter(item -> item.getProductId().equals(productId)) + .collect(Collectors.toList()); + } + + /** + * Retrieves all order items from the repository. + * + * @return A list of all order items + */ + public List findAll() { + return new ArrayList<>(orderItems.values()); + } + + /** + * Deletes an order item by ID. + * + * @param id The ID of the order item to delete + * @return true if the order item was deleted, false if not found + */ + public boolean deleteById(Long id) { + return orderItems.remove(id) != null; + } + + /** + * Deletes all order items for an order. + * + * @param orderId The ID of the order + * @return The number of order items deleted + */ + public int deleteByOrderId(Long orderId) { + List itemsToDelete = orderItems.values().stream() + .filter(item -> item.getOrderId().equals(orderId)) + .map(OrderItem::getOrderItemId) + .collect(Collectors.toList()); + + itemsToDelete.forEach(orderItems::remove); + return itemsToDelete.size(); + } + + /** + * Updates an existing order item. + * + * @param id The ID of the order item to update + * @param orderItem The updated order item information + * @return An Optional containing the updated order item, or empty if not found + */ + public Optional update(Long id, OrderItem orderItem) { + if (!orderItems.containsKey(id)) { + return Optional.empty(); + } + + orderItem.setOrderItemId(id); + orderItems.put(id, orderItem); + return Optional.of(orderItem); + } +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderRepository.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderRepository.java new file mode 100644 index 00000000..743bd26d --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/repository/OrderRepository.java @@ -0,0 +1,109 @@ +package io.microprofile.tutorial.store.order.repository; + +import io.microprofile.tutorial.store.order.entity.Order; +import io.microprofile.tutorial.store.order.entity.OrderStatus; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Simple in-memory repository for Order objects. + * This class provides CRUD operations for Order entities to demonstrate MicroProfile concepts. + */ +@ApplicationScoped +public class OrderRepository { + + private final Map orders = new HashMap<>(); + private long nextId = 1; + + /** + * Saves an order to the repository. + * If the order has no ID, a new ID is assigned. + * + * @param order The order to save + * @return The saved order with ID assigned + */ + public Order save(Order order) { + if (order.getOrderId() == null) { + order.setOrderId(nextId++); + } + orders.put(order.getOrderId(), order); + return order; + } + + /** + * Finds an order by ID. + * + * @param id The order ID + * @return An Optional containing the order if found, or empty if not found + */ + public Optional findById(Long id) { + return Optional.ofNullable(orders.get(id)); + } + + /** + * Finds orders by user ID. + * + * @param userId The user ID + * @return A list of orders for the specified user + */ + public List findByUserId(Long userId) { + return orders.values().stream() + .filter(order -> order.getUserId().equals(userId)) + .collect(Collectors.toList()); + } + + /** + * Finds orders by status. + * + * @param status The order status + * @return A list of orders with the specified status + */ + public List findByStatus(OrderStatus status) { + return orders.values().stream() + .filter(order -> order.getStatus().equals(status)) + .collect(Collectors.toList()); + } + + /** + * Retrieves all orders from the repository. + * + * @return A list of all orders + */ + public List findAll() { + return new ArrayList<>(orders.values()); + } + + /** + * Deletes an order by ID. + * + * @param id The ID of the order to delete + * @return true if the order was deleted, false if not found + */ + public boolean deleteById(Long id) { + return orders.remove(id) != null; + } + + /** + * Updates an existing order. + * + * @param id The ID of the order to update + * @param order The updated order information + * @return An Optional containing the updated order, or empty if not found + */ + public Optional update(Long id, Order order) { + if (!orders.containsKey(id)) { + return Optional.empty(); + } + + order.setOrderId(id); + orders.put(id, order); + return Optional.of(order); + } +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderItemResource.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderItemResource.java new file mode 100644 index 00000000..e20d36f5 --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderItemResource.java @@ -0,0 +1,149 @@ +package io.microprofile.tutorial.store.order.resource; + +import io.microprofile.tutorial.store.order.entity.OrderItem; +import io.microprofile.tutorial.store.order.service.OrderService; + +import java.net.URI; +import java.util.List; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +/** + * REST resource for order item operations. + */ +@Path("/orderItems") +@RequestScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "OrderItem", description = "Operations related to order item management") +public class OrderItemResource { + + @Inject + private OrderService orderService; + + @Context + private UriInfo uriInfo; + + @GET + @Path("/{id}") + @Operation(summary = "Get order item by ID", description = "Returns a specific order item by ID") + @APIResponse( + responseCode = "200", + description = "Order item", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = OrderItem.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Order item not found" + ) + public OrderItem getOrderItemById( + @Parameter(description = "ID of the order item", required = true) + @PathParam("id") Long id) { + return orderService.getOrderItemById(id); + } + + @GET + @Path("/order/{orderId}") + @Operation(summary = "Get order items by order ID", description = "Returns items for a specific order") + @APIResponse( + responseCode = "200", + description = "List of order items", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = OrderItem.class) + ) + ) + public List getOrderItemsByOrderId( + @Parameter(description = "Order ID", required = true) + @PathParam("orderId") Long orderId) { + return orderService.getOrderItemsByOrderId(orderId); + } + + @POST + @Path("/order/{orderId}") + @Operation(summary = "Add item to order", description = "Adds a new item to an existing order") + @APIResponse( + responseCode = "201", + description = "Order item added", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = OrderItem.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Order not found" + ) + public Response addOrderItem( + @Parameter(description = "ID of the order", required = true) + @PathParam("orderId") Long orderId, + @Parameter(description = "Order item details", required = true) + @NotNull @Valid OrderItem orderItem) { + OrderItem createdItem = orderService.addOrderItem(orderId, orderItem); + URI location = uriInfo.getBaseUriBuilder() + .path(OrderItemResource.class) + .path(createdItem.getOrderItemId().toString()) + .build(); + return Response.created(location).entity(createdItem).build(); + } + + @PUT + @Path("/{id}") + @Operation(summary = "Update order item", description = "Updates an existing order item") + @APIResponse( + responseCode = "200", + description = "Order item updated", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = OrderItem.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Order item not found" + ) + public OrderItem updateOrderItem( + @Parameter(description = "ID of the order item", required = true) + @PathParam("id") Long id, + @Parameter(description = "Updated order item details", required = true) + @NotNull @Valid OrderItem orderItem) { + return orderService.updateOrderItem(id, orderItem); + } + + @DELETE + @Path("/{id}") + @Operation(summary = "Delete order item", description = "Deletes an order item") + @APIResponse( + responseCode = "204", + description = "Order item deleted" + ) + @APIResponse( + responseCode = "404", + description = "Order item not found" + ) + public Response deleteOrderItem( + @Parameter(description = "ID of the order item", required = true) + @PathParam("id") Long id) { + orderService.deleteOrderItem(id); + return Response.noContent().build(); + } +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderResource.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderResource.java new file mode 100644 index 00000000..955b0442 --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/resource/OrderResource.java @@ -0,0 +1,208 @@ +package io.microprofile.tutorial.store.order.resource; + +import io.microprofile.tutorial.store.order.entity.Order; +import io.microprofile.tutorial.store.order.entity.OrderStatus; +import io.microprofile.tutorial.store.order.service.OrderService; + +import java.net.URI; +import java.util.List; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +/** + * REST resource for order operations. + */ +@Path("/orders") +@RequestScoped +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Order", description = "Operations related to order management") +public class OrderResource { + + @Inject + private OrderService orderService; + + @Context + private UriInfo uriInfo; + + @GET + @Operation(summary = "Get all orders", description = "Returns a list of all orders with their items") + @APIResponse( + responseCode = "200", + description = "List of orders", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = Order.class) + ) + ) + public List getAllOrders() { + return orderService.getAllOrders(); + } + + @GET + @Path("/{id}") + @Operation(summary = "Get order by ID", description = "Returns a specific order by ID with its items") + @APIResponse( + responseCode = "200", + description = "Order", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Order.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Order not found" + ) + public Order getOrderById( + @Parameter(description = "ID of the order", required = true) + @PathParam("id") Long id) { + return orderService.getOrderById(id); + } + + @GET + @Path("/user/{userId}") + @Operation(summary = "Get orders by user ID", description = "Returns orders for a specific user") + @APIResponse( + responseCode = "200", + description = "List of orders", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = Order.class) + ) + ) + public List getOrdersByUserId( + @Parameter(description = "User ID", required = true) + @PathParam("userId") Long userId) { + return orderService.getOrdersByUserId(userId); + } + + @GET + @Path("/status/{status}") + @Operation(summary = "Get orders by status", description = "Returns orders with a specific status") + @APIResponse( + responseCode = "200", + description = "List of orders", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = SchemaType.ARRAY, implementation = Order.class) + ) + ) + public List getOrdersByStatus( + @Parameter(description = "Order status", required = true) + @PathParam("status") String status) { + try { + OrderStatus orderStatus = OrderStatus.valueOf(status.toUpperCase()); + return orderService.getOrdersByStatus(orderStatus); + } catch (IllegalArgumentException e) { + throw new WebApplicationException("Invalid order status: " + status, Response.Status.BAD_REQUEST); + } + } + + @POST + @Operation(summary = "Create new order", description = "Creates a new order with items") + @APIResponse( + responseCode = "201", + description = "Order created", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Order.class) + ) + ) + public Response createOrder( + @Parameter(description = "Order details", required = true) + @NotNull @Valid Order order) { + Order createdOrder = orderService.createOrder(order); + URI location = uriInfo.getAbsolutePathBuilder().path(createdOrder.getOrderId().toString()).build(); + return Response.created(location).entity(createdOrder).build(); + } + + @PUT + @Path("/{id}") + @Operation(summary = "Update order", description = "Updates an existing order") + @APIResponse( + responseCode = "200", + description = "Order updated", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Order.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Order not found" + ) + public Order updateOrder( + @Parameter(description = "ID of the order", required = true) + @PathParam("id") Long id, + @Parameter(description = "Updated order details", required = true) + @NotNull @Valid Order order) { + return orderService.updateOrder(id, order); + } + + @PATCH + @Path("/{id}/status/{status}") + @Operation(summary = "Update order status", description = "Updates the status of an order") + @APIResponse( + responseCode = "200", + description = "Order status updated", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = Order.class) + ) + ) + @APIResponse( + responseCode = "404", + description = "Order not found" + ) + @APIResponse( + responseCode = "400", + description = "Invalid order status" + ) + public Order updateOrderStatus( + @Parameter(description = "ID of the order", required = true) + @PathParam("id") Long id, + @Parameter(description = "New order status", required = true) + @PathParam("status") String status) { + try { + OrderStatus orderStatus = OrderStatus.valueOf(status.toUpperCase()); + return orderService.updateOrderStatus(id, orderStatus); + } catch (IllegalArgumentException e) { + throw new WebApplicationException("Invalid order status: " + status, Response.Status.BAD_REQUEST); + } + } + + @DELETE + @Path("/{id}") + @Operation(summary = "Delete order", description = "Deletes an order and its items") + @APIResponse( + responseCode = "204", + description = "Order deleted" + ) + @APIResponse( + responseCode = "404", + description = "Order not found" + ) + public Response deleteOrder( + @Parameter(description = "ID of the order", required = true) + @PathParam("id") Long id) { + orderService.deleteOrder(id); + return Response.noContent().build(); + } +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/NotificationService.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/NotificationService.java new file mode 100644 index 00000000..d6dc58ed --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/NotificationService.java @@ -0,0 +1,20 @@ +package io.microprofile.tutorial.store.order.service; + +import io.microprofile.tutorial.store.order.entity.Order; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.logging.Logger; + +@ApplicationScoped +public class NotificationService { + + private static final Logger logger = Logger.getLogger(NotificationService.class.getName()); + + public void notifyOrderCreated(Order order) { + logger.info("Order created: orderId=" + order.getOrderId()); + } + + public void notifyOrderStatusChanged(Order order) { + logger.info("Order status changed: orderId=" + order.getOrderId() + ", status=" + order.getStatus()); + } +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderService.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderService.java new file mode 100644 index 00000000..d50aa80d --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderService.java @@ -0,0 +1,372 @@ +package io.microprofile.tutorial.store.order.service; + +import io.microprofile.tutorial.store.order.entity.Order; +import io.microprofile.tutorial.store.order.entity.OrderItem; +import io.microprofile.tutorial.store.order.entity.OrderStatus; +import io.microprofile.tutorial.store.order.repository.OrderItemRepository; +import io.microprofile.tutorial.store.order.repository.OrderRepository; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +/** + * Service class for Order management operations. + */ +@ApplicationScoped +public class OrderService { + + @Inject + private OrderRepository orderRepository; + + @Inject + private OrderItemRepository orderItemRepository; + + @Inject + @Channel("order-created") + private Emitter orderCreatedEmitter; + + /** + * Creates a new order with items. + * + * @param order The order to create + * @return The created order + */ + @Transactional + public Order createOrder(Order order) { + // Set default values + if (order.getStatus() == null) { + order.setStatus(OrderStatus.CREATED); + } + + order.setCreatedAt(LocalDateTime.now()); + order.setUpdatedAt(LocalDateTime.now()); + + // Calculate total price from order items if not specified + if (order.getTotalPrice() == null || order.getTotalPrice().compareTo(BigDecimal.ZERO) == 0) { + BigDecimal total = order.getOrderItems().stream() + .map(item -> item.getPriceAtOrder().multiply(new BigDecimal(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + order.setTotalPrice(total); + } + + // Save the order first + Order savedOrder = orderRepository.save(order); + + // Save each order item + if (order.getOrderItems() != null && !order.getOrderItems().isEmpty()) { + for (OrderItem item : order.getOrderItems()) { + item.setOrderId(savedOrder.getOrderId()); + orderItemRepository.save(item); + } + } + + // Retrieve the complete order with items + Order savedResult = getOrderById(savedOrder.getOrderId()); + orderCreatedEmitter.send(savedResult.getOrderId()); + return savedResult; + } + + /** + * Gets an order by ID with its items. + * + * @param id The order ID + * @return The order with its items + * @throws WebApplicationException if the order is not found + */ + public Order getOrderById(Long id) { + Order order = orderRepository.findById(id) + .orElseThrow(() -> new WebApplicationException("Order not found", Response.Status.NOT_FOUND)); + + // Load order items + List items = orderItemRepository.findByOrderId(id); + order.setOrderItems(items); + + return order; + } + + /** + * Gets all orders with their items. + * + * @return A list of all orders with their items + */ + public List getAllOrders() { + List orders = orderRepository.findAll(); + + // Load items for each order + for (Order order : orders) { + List items = orderItemRepository.findByOrderId(order.getOrderId()); + order.setOrderItems(items); + } + + return orders; + } + + /** + * Gets orders by user ID. + * + * @param userId The user ID + * @return A list of orders for the specified user + */ + public List getOrdersByUserId(Long userId) { + List orders = orderRepository.findByUserId(userId); + + // Load items for each order + for (Order order : orders) { + List items = orderItemRepository.findByOrderId(order.getOrderId()); + order.setOrderItems(items); + } + + return orders; + } + + /** + * Gets orders by status. + * + * @param status The order status + * @return A list of orders with the specified status + */ + public List getOrdersByStatus(OrderStatus status) { + List orders = orderRepository.findByStatus(status); + + // Load items for each order + for (Order order : orders) { + List items = orderItemRepository.findByOrderId(order.getOrderId()); + order.setOrderItems(items); + } + + return orders; + } + + /** + * Updates an order. + * + * @param id The order ID + * @param order The updated order information + * @return The updated order + * @throws WebApplicationException if the order is not found + */ + @Transactional + public Order updateOrder(Long id, Order order) { + // Check if order exists + if (!orderRepository.findById(id).isPresent()) { + throw new WebApplicationException("Order not found", Response.Status.NOT_FOUND); + } + + order.setOrderId(id); + order.setUpdatedAt(LocalDateTime.now()); + + // Handle order items if provided + if (order.getOrderItems() != null && !order.getOrderItems().isEmpty()) { + // Delete existing items for this order + orderItemRepository.deleteByOrderId(id); + + // Save new items + for (OrderItem item : order.getOrderItems()) { + item.setOrderId(id); + orderItemRepository.save(item); + } + + // Recalculate total price from order items + BigDecimal total = order.getOrderItems().stream() + .map(item -> item.getPriceAtOrder().multiply(new BigDecimal(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + order.setTotalPrice(total); + } + + // Update the order + Order updatedOrder = orderRepository.update(id, order) + .orElseThrow(() -> new WebApplicationException("Failed to update order", Response.Status.INTERNAL_SERVER_ERROR)); + + // Reload items + List items = orderItemRepository.findByOrderId(id); + updatedOrder.setOrderItems(items); + + return updatedOrder; + } + + /** + * Updates the status of an order. + * + * @param id The order ID + * @param status The new status + * @return The updated order + * @throws WebApplicationException if the order is not found + */ + public Order updateOrderStatus(Long id, OrderStatus status) { + Order order = orderRepository.findById(id) + .orElseThrow(() -> new WebApplicationException("Order not found", Response.Status.NOT_FOUND)); + + order.setStatus(status); + order.setUpdatedAt(LocalDateTime.now()); + + Order updatedOrder = orderRepository.update(id, order) + .orElseThrow(() -> new WebApplicationException("Failed to update order status", Response.Status.INTERNAL_SERVER_ERROR)); + + // Reload items + List items = orderItemRepository.findByOrderId(id); + updatedOrder.setOrderItems(items); + + if (status == OrderStatus.PAID) { + // status updated; notification handled by OrderStatusEventHandler via Kafka + } + + return updatedOrder; + } + + /** + * Deletes an order and its items. + * + * @param id The order ID + * @throws WebApplicationException if the order is not found + */ + @Transactional + public void deleteOrder(Long id) { + // Check if order exists + if (!orderRepository.findById(id).isPresent()) { + throw new WebApplicationException("Order not found", Response.Status.NOT_FOUND); + } + + // Delete order items first + orderItemRepository.deleteByOrderId(id); + + // Delete the order + boolean deleted = orderRepository.deleteById(id); + if (!deleted) { + throw new WebApplicationException("Failed to delete order", Response.Status.INTERNAL_SERVER_ERROR); + } + } + + /** + * Gets an order item by ID. + * + * @param id The order item ID + * @return The order item + * @throws WebApplicationException if the order item is not found + */ + public OrderItem getOrderItemById(Long id) { + return orderItemRepository.findById(id) + .orElseThrow(() -> new WebApplicationException("Order item not found", Response.Status.NOT_FOUND)); + } + + /** + * Gets order items by order ID. + * + * @param orderId The order ID + * @return A list of order items for the specified order + */ + public List getOrderItemsByOrderId(Long orderId) { + return orderItemRepository.findByOrderId(orderId); + } + + /** + * Adds an item to an order. + * + * @param orderId The order ID + * @param orderItem The order item to add + * @return The added order item + * @throws WebApplicationException if the order is not found + */ + @Transactional + public OrderItem addOrderItem(Long orderId, OrderItem orderItem) { + // Check if order exists + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new WebApplicationException("Order not found", Response.Status.NOT_FOUND)); + + orderItem.setOrderId(orderId); + OrderItem savedItem = orderItemRepository.save(orderItem); + + // Update order total price + List items = orderItemRepository.findByOrderId(orderId); + BigDecimal total = items.stream() + .map(item -> item.getPriceAtOrder().multiply(new BigDecimal(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + order.setTotalPrice(total); + order.setUpdatedAt(LocalDateTime.now()); + orderRepository.update(orderId, order); + + return savedItem; + } + + /** + * Updates an order item. + * + * @param itemId The order item ID + * @param orderItem The updated order item + * @return The updated order item + * @throws WebApplicationException if the order item is not found + */ + @Transactional + public OrderItem updateOrderItem(Long itemId, OrderItem orderItem) { + // Check if item exists + OrderItem existingItem = orderItemRepository.findById(itemId) + .orElseThrow(() -> new WebApplicationException("Order item not found", Response.Status.NOT_FOUND)); + + // Keep the same orderId + orderItem.setOrderItemId(itemId); + orderItem.setOrderId(existingItem.getOrderId()); + + OrderItem updatedItem = orderItemRepository.update(itemId, orderItem) + .orElseThrow(() -> new WebApplicationException("Failed to update order item", Response.Status.INTERNAL_SERVER_ERROR)); + + // Update order total price + Long orderId = updatedItem.getOrderId(); + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new WebApplicationException("Order not found", Response.Status.INTERNAL_SERVER_ERROR)); + + List items = orderItemRepository.findByOrderId(orderId); + BigDecimal total = items.stream() + .map(item -> item.getPriceAtOrder().multiply(new BigDecimal(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + order.setTotalPrice(total); + order.setUpdatedAt(LocalDateTime.now()); + orderRepository.update(orderId, order); + + return updatedItem; + } + + /** + * Deletes an order item. + * + * @param itemId The order item ID + * @throws WebApplicationException if the order item is not found + */ + @Transactional + public void deleteOrderItem(Long itemId) { + // Check if item exists and get its orderId before deletion + OrderItem item = orderItemRepository.findById(itemId) + .orElseThrow(() -> new WebApplicationException("Order item not found", Response.Status.NOT_FOUND)); + + Long orderId = item.getOrderId(); + + // Delete the item + boolean deleted = orderItemRepository.deleteById(itemId); + if (!deleted) { + throw new WebApplicationException("Failed to delete order item", Response.Status.INTERNAL_SERVER_ERROR); + } + + // Update order total price + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new WebApplicationException("Order not found", Response.Status.INTERNAL_SERVER_ERROR)); + + List items = orderItemRepository.findByOrderId(orderId); + BigDecimal total = items.stream() + .map(i -> i.getPriceAtOrder().multiply(new BigDecimal(i.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + order.setTotalPrice(total); + order.setUpdatedAt(LocalDateTime.now()); + orderRepository.update(orderId, order); + } +} diff --git a/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderStatusEventHandler.java b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderStatusEventHandler.java new file mode 100644 index 00000000..d82e1106 --- /dev/null +++ b/code/chapter13/order/src/main/java/io/microprofile/tutorial/store/order/service/OrderStatusEventHandler.java @@ -0,0 +1,24 @@ +package io.microprofile.tutorial.store.order.service; + +import org.eclipse.microprofile.reactive.messaging.Incoming; + +import io.microprofile.tutorial.store.order.entity.OrderStatus; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import io.microprofile.tutorial.store.order.entity.Order; + +@ApplicationScoped +public class OrderStatusEventHandler { + + @Inject + OrderService orderService; + + @Inject + NotificationService notificationService; + + @Incoming("payment-authorized") + public void onPaymentAuthorized(Long orderId) { + Order updatedOrder = orderService.updateOrderStatus(orderId, OrderStatus.PAID); + notificationService.notifyOrderStatusChanged(updatedOrder); + } +} \ No newline at end of file diff --git a/code/chapter13/order/src/main/resources/META-INF/microprofile-config.properties b/code/chapter13/order/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 00000000..70dd6ae1 --- /dev/null +++ b/code/chapter13/order/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,10 @@ +# Minimal setup: no external broker is required. +# The order service publishes to order-created and consumes from payment-authorized. + +# Kafka configuration for production: +mp.messaging.connector.liberty-kafka.bootstrap.servers=localhost:9092 +mp.messaging.outgoing.order-created.connector=liberty-kafka +mp.messaging.outgoing.order-created.topic=order-created-topic +mp.messaging.incoming.payment-authorized.connector=liberty-kafka +mp.messaging.incoming.payment-authorized.topic=payment-authorized-topic +mp.messaging.incoming.payment-authorized.group.id=order-service-group diff --git a/code/chapter13/order/src/main/webapp/WEB-INF/beans.xml b/code/chapter13/order/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 00000000..d61f0180 --- /dev/null +++ b/code/chapter13/order/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,8 @@ + + + diff --git a/code/chapter13/order/src/main/webapp/WEB-INF/web.xml b/code/chapter13/order/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..6a516f16 --- /dev/null +++ b/code/chapter13/order/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,10 @@ + + + Order Management + + index.html + + diff --git a/code/chapter13/order/src/main/webapp/index.html b/code/chapter13/order/src/main/webapp/index.html new file mode 100644 index 00000000..1d427823 --- /dev/null +++ b/code/chapter13/order/src/main/webapp/index.html @@ -0,0 +1,144 @@ + + + + + + Order Management Service + + + +

Order Management Service

+

Welcome to the Order Management API, a Jakarta EE and MicroProfile demo.

+ +

Available Endpoints:

+ +
+

OpenAPI Documentation

+

GET /openapi - Access OpenAPI documentation

+ View API Documentation +
+ +

Order Operations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodURLDescription
GET/api/ordersGet all orders
GET/api/orders/{id}Get order by ID
GET/api/orders/user/{userId}Get orders by user ID
GET/api/orders/status/{status}Get orders by status
POST/api/ordersCreate new order
PUT/api/orders/{id}Update order
DELETE/api/orders/{id}Delete order
PATCH/api/orders/{id}/status/{status}Update order status
+ +

Order Item Operations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodURLDescription
GET/api/orderItems/order/{orderId}Get items for an order
GET/api/orderItems/{orderItemId}Get specific order item
POST/api/orderItems/order/{orderId}Add item to order
PUT/api/orderItems/{orderItemId}Update order item
DELETE/api/orderItems/{orderItemId}Delete order item
+ +

Example Request

+
curl -X GET http://localhost:8050/order/api/orders
+ + diff --git a/code/chapter13/order/src/main/webapp/order-status-codes.html b/code/chapter13/order/src/main/webapp/order-status-codes.html new file mode 100644 index 00000000..faed8a09 --- /dev/null +++ b/code/chapter13/order/src/main/webapp/order-status-codes.html @@ -0,0 +1,75 @@ + + + + + + Order Status Codes + + + +

Order Status Codes

+

This page describes the possible status codes for orders in the system.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Status CodeDescription
CREATEDOrder has been created but not yet processed
PAIDPayment has been received for the order
PROCESSINGOrder is being processed (items are being picked, packed, etc.)
SHIPPEDOrder has been shipped to the customer
DELIVEREDOrder has been delivered to the customer
CANCELLEDOrder has been cancelled
+ +

Return to main page

+ + diff --git a/code/chapter13/payment/README.adoc b/code/chapter13/payment/README.adoc new file mode 100644 index 00000000..ae9525dc --- /dev/null +++ b/code/chapter13/payment/README.adoc @@ -0,0 +1,115 @@ += Payment Reactive Messaging + +The project focuses on a single CDI messaging bean that demonstrates a full in-memory channel pipeline: + +* On startup, `PaymentRequestHandler` injects an `Emitter` and sends a sample `Order` to the `order-created` channel. +* `PaymentRequestHandler.processPayment(...)` consumes from the `order-created` channel, sets the order status to `PAID`, and forwards the updated order to the `payment-authorized` channel. +* `PaymentRequestHandler.handlePaidOrder(...)` consumes from the `payment-authorized` channel and logs the completed payment. + +== How it works + +[source,text] +---- ++------------------------------------------------+ +| PaymentRequestHandler | +| | +| @Inject @Channel("order-created") | +| Emitter — fires once on startup | ++----------------------+-------------------------+ + | + v + +------------------+ + | order-created | + | channel | + +--------+---------+ + | + v ++------------------------------------------------+ +| PaymentRequestHandler | +| | +| @Incoming("order-created") | +| @Outgoing("payment-authorized") | +| processPayment(Order): Order (status = PAID) | ++----------------------+-------------------------+ + | + v + +--------------------+ + | payment-authorized | + | channel | + +--------+-----------+ + | + v ++------------------------------------------------+ +| PaymentRequestHandler | +| | +| @Incoming("payment-authorized") | +| handlePaidOrder(Order): void | ++------------------------------------------------+ +---- + +== Project structure + +[source,text] +---- +src/ +└── main/ + ├── java/ + │ └── io/microprofile/tutorial/store/payment/ + │ ├── entity/ + │ │ ├── Order.java # minimal order payload + │ │ └── OrderStatus.java # order status enum + │ └── service/ + │ └── PaymentRequestHandler.java # reactive messaging emitter and processors + ├── liberty/ + │ └── config/ + │ └── server.xml # Open Liberty runtime configuration + ├── resources/ + │ └── META-INF/ + │ └── microprofile-config.properties # messaging configuration notes + └── webapp/ + └── WEB-INF/ + └── beans.xml # CDI bean discovery +---- + +== Prerequisites + +* Java 21 +* Maven 3.8+ + +== Build the project + +[source,bash] +---- +cd code/chapter13/payment +mvn clean package +---- + +== Run the application + +[source,bash] +---- +mvn liberty:run +---- + +The application is deployed by Open Liberty at: + +* `http://localhost:9080/payment/` + +== What to expect + +This minimal version does not expose any HTTP API for submitting orders. + +When the application starts, `PaymentRequestHandler` uses an injected `Emitter` to send a single sample order into the `order-created` channel. The `processPayment` method processes it and forwards the updated order to the `payment-authorized` channel. Finally, `handlePaidOrder` logs the completed payment. + +Typical log entries look like: + +[source,text] +---- +[INFO] [4/5/26, 4:38:47:582 UTC] 0000002d profile.tutorial.store.payment.service.PaymentRequestHandler I Created order 1001 for user 1 +[INFO] [4/5/26, 4:38:47:587 UTC] 0000002d profile.tutorial.store.payment.service.PaymentRequestHandler I Processing payment for order 1001 +[INFO] [4/5/26, 4:38:47:588 UTC] 0000002d profile.tutorial.store.payment.service.PaymentRequestHandler I Order 1001 marked as PAID for user 1 +---- + +== Stop the application + +Press `Ctrl+C` in the terminal running `mvn liberty:run`. diff --git a/code/chapter13/payment/pom.xml b/code/chapter13/payment/pom.xml new file mode 100644 index 00000000..dcd53959 --- /dev/null +++ b/code/chapter13/payment/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + io.microprofile.tutorial + payment + 1.0-SNAPSHOT + war + + payment + + + 21 + UTF-8 + 9080 + 9443 + 1.18.36 + + + + + org.eclipse.microprofile + microprofile + 7.1 + pom + provided + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + org.eclipse.microprofile.reactive.messaging + microprofile-reactive-messaging-api + 3.0.1 + provided + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + org.apache.kafka + kafka-clients + 3.7.0 + + + + + + ${project.artifactId} + + + org.apache.maven.plugins + maven-war-plugin + 3.3.2 + + false + + + + + io.openliberty.tools + liberty-maven-plugin + 3.11.4 + + PaymentServer + + + + + diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/PaymentRestApplication.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/PaymentRestApplication.java new file mode 100644 index 00000000..9ffd7515 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/PaymentRestApplication.java @@ -0,0 +1,9 @@ +package io.microprofile.tutorial; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +@ApplicationPath("/api") +public class PaymentRestApplication extends Application { + // No additional configuration is needed here +} \ No newline at end of file diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClient.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClient.java new file mode 100644 index 00000000..81576184 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClient.java @@ -0,0 +1,67 @@ +package io.microprofile.tutorial.store.payment.client; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; + +import io.microprofile.tutorial.store.payment.dto.product.Product; +import io.microprofile.tutorial.store.payment.exception.ProductNotFoundException; + +import java.util.List; + +/** + * MicroProfile Rest Client interface for the Catalog/Product Service. + * + * This interface demonstrates: + * - @RegisterRestClient annotation to register as a REST client + * - @RegisterProvider to register custom exception mapper + * - configKey for external configuration via MicroProfile Config + * - Type-safe method definitions with Jakarta REST annotations + * - Automatic implementation generation by MicroProfile runtime + * - Custom error handling via ResponseExceptionMapper + * + * Configuration properties (in microprofile-config.properties): + * - catalog-service/mp-rest/url=http://localhost:5050/catalog/api + * - catalog-service/mp-rest/scope=jakarta.enterprise.context.ApplicationScoped + * + * This interface extends AutoCloseable to support try-with-resources pattern + * when using RestClientBuilder for programmatic client creation. + */ +@RegisterRestClient(configKey = "catalog-service") +@RegisterProvider(ProductServiceResponseExceptionMapper.class) +@Path("/products") +@Produces(MediaType.APPLICATION_JSON) +public interface ProductClient extends AutoCloseable { + + /** + * Retrieves all products from the catalog service. + * + * @return List of all products + * @throws RuntimeException if service returns 5xx error + */ + @GET + List getAllProducts(); + + /** + * Retrieves a specific product by its ID. + * + * Example usage: productClient.getProductById(1L) + * Resulting HTTP request: GET /products/1 + * + * Demonstrates checked exception handling: + * - Throws ProductNotFoundException if product not found (404) + * - Method must declare this checked exception in throws clause + * + * @param id The product ID + * @return The product with the specified ID + * @throws ProductNotFoundException if product is not found (404) + */ + @GET + @Path("/{id}") + Product getProductById(@PathParam("id") Long id) throws ProductNotFoundException; +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClientWithFilters.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClientWithFilters.java new file mode 100644 index 00000000..b5589227 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductClientWithFilters.java @@ -0,0 +1,89 @@ +package io.microprofile.tutorial.store.payment.client; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; + +import io.microprofile.tutorial.store.payment.dto.product.Product; +import io.microprofile.tutorial.store.payment.exception.ProductNotFoundException; +import io.microprofile.tutorial.store.payment.filter.BearerTokenFilter; +import io.microprofile.tutorial.store.payment.filter.CorrelationIdFilter; +import io.microprofile.tutorial.store.payment.filter.RequestLoggingFilter; +import io.microprofile.tutorial.store.payment.filter.ResponseLoggingFilter; + +import java.util.List; + +/** + * MicroProfile Rest Client with custom filters and interceptors registered. + * + * This interface demonstrates: + * - Registering multiple filters using @RegisterProvider + * - Specifying filter priority to control execution order + * - Combining authentication, logging, and tracing filters + * - How filters execute in the request/response lifecycle + * + * Filter Execution Order (Request): + * 1. BearerTokenFilter (Priority 1000 - AUTHENTICATION) + * - Adds Authorization header with Bearer token + * 2. CorrelationIdFilter (Priority 100) + * - Adds X-Correlation-ID and X-Request-ID headers + * 3. RequestLoggingFilter (Priority 300) + * - Logs complete request details + * + * Filter Execution Order (Response): + * 1. ResponseLoggingFilter (Priority 300) + * - Logs complete response details + * + * Compare this client with ProductClient to see the difference in logging output. + * + * Configuration properties (in microprofile-config.properties): + * - catalog-service-filtered/mp-rest/url=http://localhost:5050/catalog/api + * - catalog-service-filtered/mp-rest/scope=jakarta.enterprise.context.ApplicationScoped + * - catalog-service.bearer.token (optional) + */ +@RegisterRestClient(configKey = "catalog-service-filtered") +@RegisterProvider(value = BearerTokenFilter.class, priority = 1000) +@RegisterProvider(value = CorrelationIdFilter.class, priority = 100) +@RegisterProvider(value = RequestLoggingFilter.class, priority = 300) +@RegisterProvider(value = ResponseLoggingFilter.class, priority = 300) +@Path("/products") +@Produces(MediaType.APPLICATION_JSON) +public interface ProductClientWithFilters extends AutoCloseable { + + /** + * Retrieves all products from the catalog service. + * + * When called, you will see filter execution in the logs: + * 1. BearerTokenFilter adds authentication + * 2. CorrelationIdFilter adds tracking IDs + * 3. RequestLoggingFilter logs the outgoing request + * 4. HTTP request is sent + * 5. HTTP response is received + * 6. ResponseLoggingFilter logs the incoming response + * 7. Response is deserialized to List + * + * @return List of all products + * @throws RuntimeException if service returns 5xx error + */ + @GET + List getAllProducts(); + + /** + * Retrieves a specific product by its ID. + * + * Demonstrates the complete filter chain with path parameters. + * Check the logs to see how filters handle parameterized requests. + * + * @param id The product ID + * @return The product with the specified ID + * @throws ProductNotFoundException if product is not found (404) + */ + @GET + @Path("/{id}") + Product getProductById(@PathParam("id") Long id) throws ProductNotFoundException; +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductJakartaRestClient.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductJakartaRestClient.java new file mode 100644 index 00000000..095a448e --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductJakartaRestClient.java @@ -0,0 +1,55 @@ +package io.microprofile.tutorial.store.payment.client; + +import io.microprofile.tutorial.store.payment.dto.product.Product; +import jakarta.json.JsonArray; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +public class ProductJakartaRestClient { + public static Product[] getProductsWithJsonb(String targetUrl) { + // This method would typically make a REST call to fetch products. + // For now, we return an empty array as a placeholder. + Client client = ClientBuilder.newClient(); + Response response = client.target(targetUrl) + .request(MediaType.APPLICATION_JSON) + .get(); + + Product[] products = response.readEntity(Product[].class); + response.close(); + client.close(); + + + return products; + } + + + public static Product[] getProductsWithJsonp(String targetUrl) { + // Default URL for product service + String defaultUrl = "http://localhost:5050/products"; + Client client = ClientBuilder.newClient(); + Response response = client.target(targetUrl != null ? targetUrl : defaultUrl) + .request(MediaType.APPLICATION_JSON) + .get(); + + JsonArray jsonArray = response.readEntity(JsonArray.class); + response.close(); + client.close(); + + return collectProducts(jsonArray); + } + + private static Product[] collectProducts(JsonArray jsonArray) { + Product[] products = new Product[jsonArray.size()]; + for (int i = 0; i < jsonArray.size(); i++) { + Product product = new Product(); + product.setId(jsonArray.getJsonObject(i).getJsonNumber("id").longValue()); + product.setName(jsonArray.getJsonObject(i).getString("name")); + product.setPrice(jsonArray.getJsonObject(i).getJsonNumber("price").doubleValue()); + products[i] = product; + } + return products; + } +} + diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductJakartaRestClientSimple.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductJakartaRestClientSimple.java new file mode 100644 index 00000000..fdb98560 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductJakartaRestClientSimple.java @@ -0,0 +1,53 @@ +package io.microprofile.tutorial.store.payment.client; + +import io.microprofile.tutorial.store.payment.dto.product.Product; +import jakarta.json.JsonArray; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +public class ProductJakartaRestClientSimple { + + public static Product[] getProductsWithJsonb(String targetUrl) { + // This method would typically make a REST call to fetch products. + // For now, we return an empty array as a placeholder. + Client client = ClientBuilder.newClient(); + Response response = client.target(targetUrl) + .request(MediaType.APPLICATION_JSON) + .get(); + + Product[] products = response.readEntity(Product[].class); + response.close(); + client.close(); + + return products; + } + + public static Product[] getProductsWithJsonp(String targetUrl) { + // Default URL for product service + String defaultUrl = "http://localhost:6050/products"; + Client client = ClientBuilder.newClient(); + Response response = client.target(targetUrl != null ? targetUrl : defaultUrl) + .request(MediaType.APPLICATION_JSON) + .get(); + + JsonArray jsonArray = response.readEntity(JsonArray.class); + response.close(); + client.close(); + + return collectProducts(jsonArray); + } + + private static Product[] collectProducts(JsonArray jsonArray) { + Product[] products = new Product[jsonArray.size()]; + for (int i = 0; i < jsonArray.size(); i++) { + Product product = new Product(); + product.setId(jsonArray.getJsonObject(i).getJsonNumber("id").longValue()); + product.setName(jsonArray.getJsonObject(i).getString("name")); + product.setPrice(jsonArray.getJsonObject(i).getJsonNumber("price").doubleValue()); + products[i] = product; + } + return products; + } +} \ No newline at end of file diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductServiceResponseExceptionMapper.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductServiceResponseExceptionMapper.java new file mode 100644 index 00000000..c70fdeb5 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/client/ProductServiceResponseExceptionMapper.java @@ -0,0 +1,140 @@ +package io.microprofile.tutorial.store.payment.client; + +import jakarta.annotation.Priority; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.rest.client.ext.ResponseExceptionMapper; + +import io.microprofile.tutorial.store.payment.exception.ProductNotFoundException; +import io.microprofile.tutorial.store.payment.exception.ServiceUnavailableException; + +import java.io.InputStream; +import java.util.logging.Logger; + +/** + * ResponseExceptionMapper for ProductClient. + * + * This class demonstrates: + * - Mapping HTTP error responses to custom exceptions + * - Extracting error messages from JSON response bodies + * - Distinguishing between checked and unchecked exceptions + * - Using @Priority for mapper ordering + * + * HTTP Status Code Mappings: + * - 404 → ProductNotFoundException (checked exception) + * - 503 → ServiceUnavailableException (unchecked exception) + * - 500-599 → RuntimeException for server errors + * - 400-499 → RuntimeException for client errors + */ +@Priority(100) +public class ProductServiceResponseExceptionMapper implements ResponseExceptionMapper { + + private static final Logger LOGGER = Logger.getLogger(ProductServiceResponseExceptionMapper.class.getName()); + + /** + * Determines if this mapper shouldhandle the given response. + * + * @param status HTTP status code + * @param headers Response headers + * @return true if status >= 400 (client and server errors) + */ + @Override + public boolean handles(int status, MultivaluedMap headers) { + // Handle all error responses (4xx and 5xx) + boolean shouldHandle = status >= 400; + if (shouldHandle) { + LOGGER.info("ResponseExceptionMapper handling error response: " + status); + } + return shouldHandle; + } + + /** + * Converts HTTP response to an appropriate exception. + * + * Demonstrates: + * - Status code based exception mapping + * - JSON error message extraction + * - Checked vs unchecked exception handling + * + * @param response The HTTP response + * @return Throwable to be thrown by the REST client + */ + @Override + public Throwable toThrowable(Response response) { + int status = response.getStatus(); + String errorMessage = extractErrorMessage(response); + + LOGGER.warning(String.format("Mapping HTTP %d to exception: %s", status, errorMessage)); + + // Map specific status codes to custom exceptions + switch (status) { + case 404: + // Checked exception - only thrown if client method declares it + return new ProductNotFoundException("Product not found: " + errorMessage); + + case 503: + // Unchecked exception - always thrown + return new ServiceUnavailableException( + "Catalog service temporarily unavailable: " + errorMessage, status); + + case 500: + case 502: + case 504: + // Server errors - unchecked exceptions + return new RuntimeException( + String.format("Catalog service error (%d): %s", status, errorMessage)); + + default: + if (status >= 500) { + // Other 5xx errors + return new RuntimeException( + String.format("Server error (%d): %s", status, errorMessage)); + } else { + // Client errors (4xx) + return new RuntimeException( + String.format("Client error (%d): %s", status, errorMessage)); + } + } + } + + /** + * Extracts error message from response body. + * + * Attempts to parse JSON error response with structure: + * { "error": "...", "message": "..." } + * + * Falls back to generic message if parsing fails. + * + * @param response The HTTP response + * @return Extracted error message or default message + */ + private String extractErrorMessage(Response response) { + try { + if (response.hasEntity()) { + // Attempt to read JSON error response + InputStream entityStream = response.readEntity(InputStream.class); + JsonReader jsonReader = Json.createReader(entityStream); + JsonObject errorJson = jsonReader.readObject(); + + // Try multiple common error message field names + if (errorJson.containsKey("message")) { + return errorJson.getString("message"); + } else if (errorJson.containsKey("error")) { + return errorJson.getString("error"); + } else if (errorJson.containsKey("errorMessage")) { + return errorJson.getString("errorMessage"); + } + } + } catch (Exception e) { + // If JSON parsing fails, log and continue with default message + LOGGER.fine("Failed to parse error response as JSON: " + e.getMessage()); + } + + // Default error message + return "HTTP " + response.getStatus() + " - " + response.getStatusInfo().getReasonPhrase(); + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java new file mode 100644 index 00000000..c4df4d6e --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentConfig.java @@ -0,0 +1,63 @@ +package io.microprofile.tutorial.store.payment.config; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; + +/** + * Utility class for accessing payment service configuration. + */ +public class PaymentConfig { + + private static final Config config = ConfigProvider.getConfig(); + + /** + * Gets a configuration property as a String. + * + * @param key the property key + * @return the property value + */ + public static String getConfigProperty(String key) { + return config.getValue(key, String.class); + } + + /** + * Gets a configuration property as a String with a default value. + * + * @param key the property key + * @param defaultValue the default value if the key doesn't exist + * @return the property value or the default value + */ + public static String getConfigProperty(String key, String defaultValue) { + return config.getOptionalValue(key, String.class).orElse(defaultValue); + } + + /** + * Gets a configuration property as an Integer. + * + * @param key the property key + * @return the property value as an Integer + */ + public static Integer getIntProperty(String key) { + return config.getValue(key, Integer.class); + } + + /** + * Gets a configuration property as a Boolean. + * + * @param key the property key + * @return the property value as a Boolean + */ + public static Boolean getBooleanProperty(String key) { + return config.getValue(key, Boolean.class); + } + + /** + * Updates a configuration property at runtime through the custom ConfigSource. + * + * @param key the property key + * @param value the property value + */ + public static void updateProperty(String key, String value) { + PaymentServiceConfigSource.setProperty(key, value); + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java new file mode 100644 index 00000000..25b59a4f --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/config/PaymentServiceConfigSource.java @@ -0,0 +1,60 @@ +package io.microprofile.tutorial.store.payment.config; + +import org.eclipse.microprofile.config.spi.ConfigSource; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Custom ConfigSource for Payment Service. + * This config source provides payment-specific configuration with high priority. + */ +public class PaymentServiceConfigSource implements ConfigSource { + + private static final Map properties = new HashMap<>(); + + private static final String NAME = "PaymentServiceConfigSource"; + private static final int ORDINAL = 600; // Higher ordinal means higher priority + + public PaymentServiceConfigSource() { + // Load payment service configurations dynamically + // This example uses hardcoded values for demonstration + properties.put("payment.gateway.endpoint", "https://api.paymentgateway.com"); + } + + @Override + public Map getProperties() { + return properties; + } + + @Override + public Set getPropertyNames() { + return properties.keySet(); + } + + @Override + public String getValue(String propertyName) { + return properties.get(propertyName); + } + + @Override + public String getName() { + return NAME; + } + + @Override + public int getOrdinal() { + return ORDINAL; + } + + /** + * Updates a configuration property at runtime. + * + * @param key the property key + * @param value the property value + */ + public static void setProperty(String key, String value) { + properties.put(key, value); + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/demo/ProductClientRunner.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/demo/ProductClientRunner.java new file mode 100644 index 00000000..34963e2e --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/demo/ProductClientRunner.java @@ -0,0 +1,72 @@ +package io.microprofile.tutorial.store.payment.demo; + +import io.microprofile.tutorial.store.payment.client.ProductJakartaRestClient; +import io.microprofile.tutorial.store.payment.client.ProductJakartaRestClientSimple; +import io.microprofile.tutorial.store.payment.dto.product.Product; + +import java.util.Arrays; +import java.util.logging.Logger; + +/** + * Example demonstrating how to use the ProductJakartaRestClientSimple.getProductsWithJsonp method. + */ +public class ProductClientRunner { + + private static final Logger LOGGER = Logger.getLogger(ProductClientRunner.class.getName()); + + public static void main(String[] args) { + + // Example 1: Call with default URL (http://localhost:5050/products) + LOGGER.info("=== Example 1: Using default URL ==="); + try { + Product[] products = ProductJakartaRestClientSimple.getProductsWithJsonp(null); + printProducts("Default URL", products); + } catch (Exception e) { + LOGGER.warning("Failed to fetch products with default URL: " + e.getMessage()); + } + + // Example 2: Call with custom catalog service URL + LOGGER.info("=== Example 2: Using custom catalog service URL ==="); + try { + String catalogUrl = "http://localhost:5050/catalog/api/products"; + Product[] products = ProductJakartaRestClientSimple.getProductsWithJsonp(catalogUrl); + printProducts("Custom catalog URL", products); + } catch (Exception e) { + LOGGER.warning("Failed to fetch products from catalog service: " + e.getMessage()); + } + + // Example 3: Call with different environment URLs + LOGGER.info("=== Example 3: Using different environment URLs ==="); + String[] environmentUrls = { + "http://localhost:5050/catalog/api/products", // Local catalog service + "http://localhost:6050/products", // Alternative port + "https://api.example.com/products" // External API + }; + + for (String url : environmentUrls) { + try { + LOGGER.info("Trying URL: " + url); + Product[] products = ProductJakartaRestClientSimple.getProductsWithJsonp(url); + printProducts("URL: " + url, products); + break; // Stop on first successful call + } catch (Exception e) { + LOGGER.warning("Failed to fetch from " + url + ": " + e.getMessage()); + } + } + } + + /** + * Helper method to print product information + */ + private static void printProducts(String source, Product[] products) { + LOGGER.info("Products from " + source + ":"); + if (products != null && products.length > 0) { + Arrays.stream(products) + .forEach(product -> LOGGER.info(" " + product.toString())); + LOGGER.info("Total products found: " + products.length); + } else { + LOGGER.info(" No products found"); + } + System.out.println(); // Add blank line for readability + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/dto/product/Product.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/dto/product/Product.java new file mode 100644 index 00000000..3b588438 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/dto/product/Product.java @@ -0,0 +1,10 @@ +package io.microprofile.tutorial.store.payment.dto.product; + +import lombok.Data; + +@Data +public class Product { + public Long id; + public String name; + public Double price; +} \ No newline at end of file diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/Order.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/Order.java new file mode 100644 index 00000000..782d30b7 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/Order.java @@ -0,0 +1,22 @@ +package io.microprofile.tutorial.store.payment.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * Minimal order event payload handled by the payment service. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Order implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long orderId; + private Long userId; + private OrderStatus status = OrderStatus.CREATED; +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/OrderStatus.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/OrderStatus.java new file mode 100644 index 00000000..e78192cf --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/OrderStatus.java @@ -0,0 +1,10 @@ +package io.microprofile.tutorial.store.payment.entity; + +/** + * Basic lifecycle states recognized by the payment service. + */ +public enum OrderStatus { + CREATED, + PAID, + CANCELLED +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java new file mode 100644 index 00000000..4b62460a --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/entity/PaymentDetails.java @@ -0,0 +1,18 @@ +package io.microprofile.tutorial.store.payment.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PaymentDetails { + private String cardNumber; + private String cardHolderName; + private String expiryDate; // Format MM/YY + private String securityCode; + private BigDecimal amount; +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/ProductNotFoundException.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/ProductNotFoundException.java new file mode 100644 index 00000000..1e406aa5 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/ProductNotFoundException.java @@ -0,0 +1,31 @@ +package io.microprofile.tutorial.store.payment.exception; + +/** + * Exception thrown when a product is not found in the catalog service. + * + * This is a checked exception that must be declared in method signatures. + * Used by ResponseExceptionMapper to map 404 HTTP responses. + */ +public class ProductNotFoundException extends Exception { + + private final Long productId; + + public ProductNotFoundException(String message) { + super(message); + this.productId = null; + } + + public ProductNotFoundException(String message, Long productId) { + super(message); + this.productId = productId; + } + + public ProductNotFoundException(String message, Throwable cause) { + super(message, cause); + this.productId = null; + } + + public Long getProductId() { + return productId; + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/ServiceUnavailableException.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/ServiceUnavailableException.java new file mode 100644 index 00000000..36f9a774 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/exception/ServiceUnavailableException.java @@ -0,0 +1,32 @@ +package io.microprofile.tutorial.store.payment.exception; + +/** + * Exception thrown when the catalog service is temporarily unavailable. + * + * This is an unchecked exception (RuntimeException) that can be thrown + * without being declared in method signatures. + * Used by ResponseExceptionMapper to map 503 HTTP responses. + */ +public class ServiceUnavailableException extends RuntimeException { + + private final int statusCode; + + public ServiceUnavailableException(String message) { + super(message); + this.statusCode = 503; + } + + public ServiceUnavailableException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + } + + public ServiceUnavailableException(String message, Throwable cause) { + super(message, cause); + this.statusCode = 503; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/BearerTokenFilter.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/BearerTokenFilter.java new file mode 100644 index 00000000..8c21bd68 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/BearerTokenFilter.java @@ -0,0 +1,72 @@ +package io.microprofile.tutorial.store.payment.filter; + +import jakarta.annotation.Priority; +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.Priorities; + +import org.eclipse.microprofile.config.ConfigProvider; + +import java.io.IOException; +import java.util.logging.Logger; + +/** + * Client Request Filter for Bearer token authentication. + * + * This filter demonstrates: + * - Implementing authentication via ClientRequestFilter + * - Using @Priority for proper filter ordering + * - Reading configuration from MicroProfile Config + * - Adding Authorization headers to outgoing requests + * - Best practices for handling authentication tokens + * + * Priority set to Priorities.AUTHENTICATION (1000) ensures this runs + * before most other filters, as authentication should happen first. + * + * The Bearer token is retrieved from MicroProfile Config, allowing + * different tokens for different environments without code changes. + * + * Configuration property: + * - catalog-service.bearer.token (optional) + */ +@Priority(Priorities.AUTHENTICATION) +public class BearerTokenFilter implements ClientRequestFilter { + + private static final Logger LOGGER = Logger.getLogger(BearerTokenFilter.class.getName()); + private static final String TOKEN_CONFIG_KEY = "catalog-service.bearer.token"; + + /** + * Intercepts outgoing HTTP requests to add Bearer token authentication. + * + * The filter: + * 1. Retrieves the Bearer token from MicroProfile Config + * 2. If token is configured, adds Authorization header with Bearer scheme + * 3. Logs authentication status (without exposing the token) + * + * If no token is configured, the request proceeds without authentication. + * This allows the same code to work in both authenticated and + * non-authenticated environments. + */ + @Override + public void filter(ClientRequestContext requestContext) throws IOException { + // Retrieve Bearer token from configuration + String bearerToken = ConfigProvider.getConfig() + .getOptionalValue(TOKEN_CONFIG_KEY, String.class) + .orElse(null); + + if (bearerToken != null && !bearerToken.isEmpty()) { + // Add Authorization header with Bearer token + requestContext.getHeaders() + .putSingle("Authorization", "Bearer " + bearerToken); + + LOGGER.info("Bearer token authentication added to request"); + + // Log token length (not the actual token for security) + LOGGER.fine(String.format("Token length: %d characters", bearerToken.length())); + + } else { + // No token configured - proceed without authentication + LOGGER.fine("No Bearer token configured - request sent without authentication"); + } + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/CorrelationIdFilter.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/CorrelationIdFilter.java new file mode 100644 index 00000000..6414c309 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/CorrelationIdFilter.java @@ -0,0 +1,77 @@ +package io.microprofile.tutorial.store.payment.filter; + +import jakarta.annotation.Priority; +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.core.MultivaluedMap; + +import java.io.IOException; +import java.util.UUID; +import java.util.logging.Logger; + +/** + * Client Request Filter that adds or propagates correlation IDs for distributed tracing. + * + * This filter demonstrates: + * - Implementing ClientRequestFilter interface + * - Using @Priority to control filter execution order + * - Adding custom headers to outgoing requests + * - Generating UUIDs for request tracking + * - Header propagation patterns in microservices + * + * Correlation IDs enable: + * - Tracing requests across multiple microservices + * - Debugging distributed transactions + * - Correlating logs from different services + * - Performance monitoring and analytics + * + * Priority 100 ensures this runs early in the filter chain. + */ +@Priority(100) +public class CorrelationIdFilter implements ClientRequestFilter { + + private static final Logger LOGGER = Logger.getLogger(CorrelationIdFilter.class.getName()); + private static final String CORRELATION_ID_HEADER = "X-Correlation-ID"; + private static final String REQUEST_ID_HEADER = "X-Request-ID"; + + /** + * Intercepts outgoing HTTP requests to add correlation tracking headers. + * + * The filter: + * 1. Checks if X-Correlation-ID already exists in the request + * 2. If not present, generates a new UUID for the correlation ID + * 3. Always generates a unique request ID for this specific request + * 4. Adds both headers to the outgoing HTTP request + * + * This enables end-to-end request tracing across microservices. + */ + @Override + public void filter(ClientRequestContext requestContext) throws IOException { + MultivaluedMap headers = requestContext.getHeaders(); + + // Check if correlation ID already exists (propagated from incoming request) + String correlationId = (String) headers.getFirst(CORRELATION_ID_HEADER); + + if (correlationId == null || correlationId.isEmpty()) { + // Generate new correlation ID for this request chain + correlationId = UUID.randomUUID().toString(); + headers.putSingle(CORRELATION_ID_HEADER, correlationId); + LOGGER.info("Generated new Correlation ID: " + correlationId); + } else { + LOGGER.info("Propagating existing Correlation ID: " + correlationId); + } + + // Always generate a unique request ID for this specific HTTP request + String requestId = UUID.randomUUID().toString(); + headers.putSingle(REQUEST_ID_HEADER, requestId); + + LOGGER.fine(String.format("Request headers added - Correlation-ID: %s, Request-ID: %s", + correlationId, requestId)); + + // Log request details with correlation context + LOGGER.info(String.format("[%s] Outgoing request: %s %s", + correlationId, + requestContext.getMethod(), + requestContext.getUri())); + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/RequestLoggingFilter.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/RequestLoggingFilter.java new file mode 100644 index 00000000..3c8f395e --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/RequestLoggingFilter.java @@ -0,0 +1,100 @@ +package io.microprofile.tutorial.store.payment.filter; + +import jakarta.annotation.Priority; +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.core.MultivaluedMap; + +import java.io.IOException; +import java.util.logging.Logger; + +/** + * Client Request Filter for comprehensive logging of outgoing HTTP requests. + * + * This filter demonstrates: + * - Implementing ClientRequestFilter for request logging + * - Using @Priority to control when the filter executes + * - Accessing request metadata (method, URI, headers) + * - Best practices for logging in filters + * - Conditional header logging to avoid sensitive data exposure + * + * Priority 300 ensures this runs after authentication and correlation ID filters, + * so all headers are present and can be logged. + * + * SECURITY NOTE: Be cautious when logging headers. Sensitive data like + * Authorization tokens should never be fully logged in production. + */ +@Priority(300) +public class RequestLoggingFilter implements ClientRequestFilter { + + private static final Logger LOGGER = Logger.getLogger(RequestLoggingFilter.class.getName()); + + // Headers that should not be logged in full (for security) + private static final String[] SENSITIVE_HEADERS = { + "Authorization", "X-API-Key", "Cookie", "Set-Cookie" + }; + + /** + * Intercepts outgoing HTTP requests for comprehensive logging. + * + * Logs: + * - HTTP method and target URI + * - Request headers (with sensitive headers masked) + * - Correlation/request tracking IDs + * - Content type information + * + * This provides visibility into REST client interactions for + * debugging, monitoring, and troubleshooting. + */ + @Override + public void filter(ClientRequestContext requestContext) throws IOException { + // Log basic request information + LOGGER.info(String.format("=== Outgoing REST Client Request ===")); + LOGGER.info(String.format("Method: %s", requestContext.getMethod())); + LOGGER.info(String.format("URI: %s", requestContext.getUri())); + + // Log media type if present + if (requestContext.getMediaType() != null) { + LOGGER.info(String.format("Content-Type: %s", requestContext.getMediaType())); + } + + // Log headers (with sensitive data masked) + MultivaluedMap headers = requestContext.getHeaders(); + LOGGER.info("Request Headers:"); + + headers.forEach((headerName, headerValues) -> { + if (isSensitiveHeader(headerName)) { + // Mask sensitive headers + LOGGER.info(String.format(" %s: [REDACTED]", headerName)); + } else { + // Log non-sensitive headers + headerValues.forEach(value -> + LOGGER.info(String.format(" %s: %s", headerName, value)) + ); + } + }); + + // Log entity class if present (for POST/PUT requests) + if (requestContext.hasEntity()) { + LOGGER.info(String.format("Entity Type: %s", + requestContext.getEntity().getClass().getSimpleName())); + } + + LOGGER.info("==================================="); + } + + /** + * Checks if a header contains sensitive information that should not be logged. + * + * @param headerName The header name to check + * @return true if the header is sensitive, false otherwise + */ + private boolean isSensitiveHeader(String headerName) { + for (String sensitiveHeader : SENSITIVE_HEADERS) { + if (sensitiveHeader.equalsIgnoreCase(headerName)) { + return true; + } + } + return false; + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/ResponseLoggingFilter.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/ResponseLoggingFilter.java new file mode 100644 index 00000000..2a2dee20 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/filter/ResponseLoggingFilter.java @@ -0,0 +1,136 @@ +package io.microprofile.tutorial.store.payment.filter; + +import jakarta.annotation.Priority; +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientResponseContext; +import jakarta.ws.rs.client.ClientResponseFilter; + +import java.io.IOException; +import java.util.logging.Logger; + +/** + * Client Response Filter for comprehensive logging of incoming HTTP responses. + * + * This filter demonstrates: + * - Implementing ClientResponseFilter for response logging + * - Using @Priority to control filter execution order + * - Accessing both request and response contexts + * - Logging response status, headers, and metadata + * - Correlating responses with their originating requests + * + * Priority 300 ensures this runs consistently with RequestLoggingFilter. + * + * Response filters execute AFTER the HTTP response is received but BEFORE + * entity deserialization, making them ideal for logging response metadata. + */ +@Priority(300) +public class ResponseLoggingFilter implements ClientResponseFilter { + + private static final Logger LOGGER = Logger.getLogger(ResponseLoggingFilter.class.getName()); + + /** + * Intercepts incoming HTTP responses for comprehensive logging. + * + * Logs: + * - Original request method and URI + * - HTTP status code and reason phrase + * - Response headers (including caching, content type) + * - Correlation IDs for tracing + * - Response timing information + * + * This provides complete visibility into REST client interactions. + * + * @param requestContext The original request context + * @param responseContext The received response context + */ + @Override + public void filter(ClientRequestContext requestContext, + ClientResponseContext responseContext) throws IOException { + + // Log basic response information + LOGGER.info(String.format("=== Incoming REST Client Response ===")); + LOGGER.info(String.format("Request: %s %s", + requestContext.getMethod(), + requestContext.getUri())); + LOGGER.info(String.format("Status: %d %s", + responseContext.getStatus(), + responseContext.getStatusInfo().getReasonPhrase())); + + // Log response content type + if (responseContext.getMediaType() != null) { + LOGGER.info(String.format("Content-Type: %s", + responseContext.getMediaType())); + } + + // Log content length if available + int contentLength = responseContext.getLength(); + if (contentLength >= 0) { + LOGGER.info(String.format("Content-Length: %d bytes", contentLength)); + } + + // Log important response headers + LOGGER.info("Response Headers:"); + + // Correlation/tracking headers + String correlationId = responseContext.getHeaderString("X-Correlation-ID"); + if (correlationId != null) { + LOGGER.info(String.format(" X-Correlation-ID: %s", correlationId)); + } + + String requestId = responseContext.getHeaderString("X-Request-ID"); + if (requestId != null) { + LOGGER.info(String.format(" X-Request-ID: %s", requestId)); + } + + // Cache control headers + String cacheControl = responseContext.getHeaderString("Cache-Control"); + if (cacheControl != null) { + LOGGER.info(String.format(" Cache-Control: %s", cacheControl)); + } + + String etag = responseContext.getHeaderString("ETag"); + if (etag != null) { + LOGGER.info(String.format(" ETag: %s", etag)); + } + + // Server information + String server = responseContext.getHeaderString("Server"); + if (server != null) { + LOGGER.info(String.format(" Server: %s", server)); + } + + // Log success or error status + if (isSuccessful(responseContext.getStatus())) { + LOGGER.info("Result: SUCCESS"); + } else if (isClientError(responseContext.getStatus())) { + LOGGER.warning(String.format("Result: CLIENT ERROR (%d)", + responseContext.getStatus())); + } else if (isServerError(responseContext.getStatus())) { + LOGGER.severe(String.format("Result: SERVER ERROR (%d)", + responseContext.getStatus())); + } + + LOGGER.info("===================================="); + } + + /** + * Checks if the HTTP status code indicates success (2xx). + */ + private boolean isSuccessful(int statusCode) { + return statusCode >= 200 && statusCode < 300; + } + + /** + * Checks if the HTTP status code indicates a client error (4xx). + */ + private boolean isClientError(int statusCode) { + return statusCode >= 400 && statusCode < 500; + } + + /** + * Checks if the HTTP status code indicates a server error (5xx). + */ + private boolean isServerError(int statusCode) { + return statusCode >= 500 && statusCode < 600; + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java new file mode 100644 index 00000000..6a4002f3 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentConfigResource.java @@ -0,0 +1,98 @@ +package io.microprofile.tutorial.store.payment.resource; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.HashMap; +import java.util.Map; + +import io.microprofile.tutorial.store.payment.config.PaymentConfig; +import io.microprofile.tutorial.store.payment.entity.PaymentDetails; + +/** + * Resource to demonstrate the use of the custom ConfigSource. + */ +@ApplicationScoped +@Path("/payment-config") +public class PaymentConfigResource { + + /** + * Get all payment configuration properties. + * + * @return Response with payment configuration + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response getPaymentConfig() { + Map configValues = new HashMap<>(); + + // Retrieve values from our custom ConfigSource + configValues.put("gateway.endpoint", PaymentConfig.getConfigProperty("payment.gateway.endpoint")); + + return Response.ok(configValues).build(); + } + + /** + * Update a payment configuration property. + * + * @param configUpdate Map containing the key and value to update + * @return Response indicating success + */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response updatePaymentConfig(Map configUpdate) { + String key = configUpdate.get("key"); + String value = configUpdate.get("value"); + + if (key == null || value == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("Both 'key' and 'value' must be provided").build(); + } + + // Only allow updating specific payment properties + if (!key.startsWith("payment.")) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("Only payment configuration properties can be updated").build(); + } + + // Update the property in our custom ConfigSource + PaymentConfig.updateProperty(key, value); + + return Response.ok(Map.of("message", "Configuration updated successfully", + "key", key, "value", value)).build(); + } + + /** + * Example of how to use the payment configuration in a real payment processing method. + * + * @param paymentDetails Payment details for processing + * @return Response with payment result + */ + @POST + @Path("/process-example") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response processPaymentExample(PaymentDetails paymentDetails) { + // Using configuration values in payment processing logic + String gatewayEndpoint = PaymentConfig.getConfigProperty("payment.gateway.endpoint"); + + // This is just for demonstration - in a real implementation, + // we would use these values to configure the payment gateway client + Map result = new HashMap<>(); + result.put("status", "success"); + result.put("message", "Payment processed successfully"); + result.put("amount", paymentDetails.getAmount()); + result.put("configUsed", Map.of( + "gatewayEndpoint", gatewayEndpoint + )); + + return Response.ok(result).build(); + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentProductResource.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentProductResource.java new file mode 100644 index 00000000..2927e7f3 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/PaymentProductResource.java @@ -0,0 +1,186 @@ +package io.microprofile.tutorial.store.payment.resource; + +import io.microprofile.tutorial.store.payment.client.ProductJakartaRestClient; +import io.microprofile.tutorial.store.payment.dto.product.Product; +import io.microprofile.tutorial.store.payment.service.ProductIntegrationService; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +/** + * REST resource demonstrating how to use ProductJakartaRestClient.getProductsWithJsonp + * within REST endpoints for the Payment service. + */ +@ApplicationScoped +@Path("/products") +public class PaymentProductResource { + + private static final Logger LOGGER = Logger.getLogger(PaymentProductResource.class.getName()); + + @Inject + private ProductIntegrationService productService; + + @Inject + @ConfigProperty(name = "catalog.service.url", defaultValue = "http://localhost:5050/catalog/api/products") + private String catalogServiceUrl; + + /** + * Gets all products available for payment processing. + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get all products", description = "Retrieves all products available for payment processing") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Products retrieved successfully"), + @APIResponse(responseCode = "500", description = "Failed to retrieve products") + }) + public Response getAllProducts() { + LOGGER.info("REST: Fetching all products for payment processing"); + + try { + Product[] products = ProductJakartaRestClient.getProductsWithJsonp(catalogServiceUrl); + + if (products != null) { + LOGGER.info("Successfully retrieved " + products.length + " products"); + return Response.ok(products).build(); + } else { + LOGGER.warning("No products returned from catalog service"); + return Response.ok(new Product[0]).build(); + } + + } catch (Exception e) { + LOGGER.severe("Failed to retrieve products: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Failed to retrieve products", "message", e.getMessage())) + .build(); + } + } + + /** + * Gets products from a specific catalog service URL. + */ + @GET + @Path("/from-url") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get products from specific URL", description = "Retrieves products from a specified catalog service URL") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Products retrieved successfully"), + @APIResponse(responseCode = "400", description = "Invalid URL provided"), + @APIResponse(responseCode = "500", description = "Failed to retrieve products") + }) + public Response getProductsFromUrl( + @Parameter(description = "Catalog service URL", required = true) + @QueryParam("url") String catalogUrl) { + + if (catalogUrl == null || catalogUrl.trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "URL parameter is required")) + .build(); + } + + LOGGER.info("REST: Fetching products from URL: " + catalogUrl); + + try { + Product[] products = ProductJakartaRestClient.getProductsWithJsonp(catalogUrl); + + Map result = new HashMap<>(); + result.put("sourceUrl", catalogUrl); + result.put("productCount", products != null ? products.length : 0); + result.put("products", products != null ? products : new Product[0]); + + return Response.ok(result).build(); + + } catch (Exception e) { + LOGGER.severe("Failed to retrieve products from " + catalogUrl + ": " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "error", "Failed to retrieve products", + "url", catalogUrl, + "message", e.getMessage())) + .build(); + } + } + + /** + * Validates if a product is available for payment processing. + */ + @GET + @Path("/{productId}/validate") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Validate product for payment", description = "Validates if a product is available for payment processing") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product validation completed"), + @APIResponse(responseCode = "404", description = "Product not found") + }) + public Response validateProduct( + @Parameter(description = "Product ID", required = true) + @PathParam("productId") Long productId) { + + LOGGER.info("REST: Validating product ID: " + productId); + + boolean isValid = productService.validateProductForPayment(productId); + Product productDetails = productService.getProductDetails(productId); + + Map result = new HashMap<>(); + result.put("productId", productId); + result.put("isValid", isValid); + result.put("availableForPayment", isValid); + + if (productDetails != null) { + result.put("product", productDetails); + } + + Response.Status status = isValid ? Response.Status.OK : Response.Status.NOT_FOUND; + return Response.status(status).entity(result).build(); + } + + /** + * Gets products within a specific price range. + */ + @GET + @Path("/price-range") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Get products by price range", description = "Retrieves products within a specified price range") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Products retrieved successfully"), + @APIResponse(responseCode = "400", description = "Invalid price range") + }) + public Response getProductsByPriceRange( + @Parameter(description = "Minimum price", required = true) + @QueryParam("minPrice") @DefaultValue("0") double minPrice, + @Parameter(description = "Maximum price", required = true) + @QueryParam("maxPrice") @DefaultValue("1000") double maxPrice) { + + if (minPrice < 0 || maxPrice < 0 || minPrice > maxPrice) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid price range. minPrice and maxPrice must be >= 0 and minPrice <= maxPrice")) + .build(); + } + + LOGGER.info(String.format("REST: Fetching products in price range: $%.2f - $%.2f", minPrice, maxPrice)); + + List products = productService.getProductsByPriceRange(minPrice, maxPrice); + + Map result = new HashMap<>(); + result.put("minPrice", minPrice); + result.put("maxPrice", maxPrice); + result.put("productCount", products.size()); + result.put("products", products); + + return Response.ok(result).build(); + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/ProductCatalogResource.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/ProductCatalogResource.java new file mode 100644 index 00000000..c1d62335 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/resource/ProductCatalogResource.java @@ -0,0 +1,853 @@ +package io.microprofile.tutorial.store.payment.resource; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import io.microprofile.tutorial.store.payment.dto.product.Product; +import io.microprofile.tutorial.store.payment.service.ProductCatalogService; +import io.microprofile.tutorial.store.payment.exception.ProductNotFoundException; +import io.microprofile.tutorial.store.payment.exception.ServiceUnavailableException; + +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +/** + * REST resource demonstrating MicroProfile Rest Client usage. + * + * This resource showcases: + * - CDI injection of service that uses REST client + * - REST endpoints that proxy calls to remote services + * - Error handling and response mapping + * - OpenAPI documentation + */ +@ApplicationScoped +@Path("/catalog") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Product Catalog", description = "REST Client demonstration endpoints") +public class ProductCatalogResource { + + private static final Logger LOGGER = Logger.getLogger(ProductCatalogResource.class.getName()); + + @Inject + ProductCatalogService catalogService; + + @Inject + io.microprofile.tutorial.store.payment.service.ProductClientBuilderService builderService; + + @Inject + io.microprofile.tutorial.store.payment.service.FilteredProductCatalogService filteredCatalogService; + + /** + * Gets all products using MicroProfile Rest Client. + * + * Demonstrates: + * - Simple GET request through REST client + * - Automatic JSON serialization + * - List response handling + */ + @GET + @Path("/products") + @Operation(summary = "Get all products", + description = "Fetches all products from the catalog service using MP Rest Client") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Products retrieved successfully"), + @APIResponse(responseCode = "503", description = "Catalog service unavailable") + }) + public Response getAllProducts() { + LOGGER.info("REST: Fetching all products via MicroProfile Rest Client"); + try { + List products = catalogService.getAllProducts(); + return Response.ok(products).build(); + } catch (Exception e) { + LOGGER.severe("Failed to fetch products: " + e.getMessage()); + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "Catalog service unavailable", "message", e.getMessage())) + .build(); + } + } + + /** + * Gets a specific product by ID. + * + * Demonstrates: + * - Path parameter usage + * - Single entity response + * - 404 handling + */ + @GET + @Path("/products/{id}") + @Operation(summary = "Get product by ID", + description = "Fetches a specific product using MP Rest Client with @PathParam") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product found"), + @APIResponse(responseCode = "404", description = "Product not found"), + @APIResponse(responseCode = "503", description = "Catalog service unavailable") + }) + public Response getProductById( + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id) { + + LOGGER.info("REST: Fetching product " + id + " via MicroProfile Rest Client"); + try { + Product product = catalogService.getProduct(id); + if (product != null) { + return Response.ok(product).build(); + } else { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Product not found", "productId", id)) + .build(); + } + } catch (Exception e) { + LOGGER.severe("Failed to fetch product: " + e.getMessage()); + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "Catalog service unavailable", "message", e.getMessage())) + .build(); + } + } + + /** + * Checks if a product is available for purchase. + * + * Demonstrates: + * - Boolean logic with REST client + * - Business validation + */ + @GET + @Path("/products/{id}/availability") + @Operation(summary = "Check product availability", + description = "Validates if a product is available for payment processing") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Availability check completed"), + @APIResponse(responseCode = "503", description = "Catalog service unavailable") + }) + public Response checkProductAvailability( + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id) { + + LOGGER.info("REST: Checking availability for product " + id); + try { + boolean available = catalogService.isProductAvailable(id); + return Response.ok(Map.of( + "productId", id, + "available", available, + "message", available ? "Product is available" : "Product is not available" + )).build(); + } catch (Exception e) { + LOGGER.severe("Failed to check availability: " + e.getMessage()); + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "Catalog service unavailable", "message", e.getMessage())) + .build(); + } + } + + /** + * Validates product price for payment processing. + * + * Demonstrates: + * - Query parameter usage + * - Price validation logic + */ + @GET + @Path("/products/{id}/validate-price") + @Operation(summary = "Validate product price", + description = "Validates if the product price matches expected amount") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Price validation completed"), + @APIResponse(responseCode = "400", description = "Invalid input"), + @APIResponse(responseCode = "503", description = "Catalog service unavailable") + }) + public Response validateProductPrice( + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id, + @Parameter(description = "Expected price", required = true) + @QueryParam("price") Double price) { + + if (price == null || price <= 0) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Invalid price", "message", "Price must be greater than 0")) + .build(); + } + + LOGGER.info(String.format("REST: Validating price for product %d (expected: %.2f)", id, price)); + try { + boolean valid = catalogService.validateProductPrice(id, price); + return Response.ok(Map.of( + "productId", id, + "expectedPrice", price, + "valid", valid, + "message", valid ? "Price matches" : "Price mismatch detected" + )).build(); + } catch (Exception e) { + LOGGER.severe("Failed to validate price: " + e.getMessage()); + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "Catalog service unavailable", "message", e.getMessage())) + .build(); + } + } + + /** + * Demonstrates exception handling with ResponseExceptionMapper. + * + * This endpoint shows how different HTTP error codes are mapped to exceptions: + * - 404 → ProductNotFoundException (checked exception) + * - 503 → ServiceUnavailableException (unchecked exception) + * - Other errors → RuntimeException + * + * Try with different IDs to see error handling: + * - Valid ID (e.g., 1) → Returns product + * - Invalid ID (e.g., 999999) → 404 ProductNotFoundException + */ + @GET + @Path("/products/{id}/detailed") + @Operation(summary = "Get product with detailed error handling", + description = "Demonstrates ResponseExceptionMapper with custom exception handling") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Product found"), + @APIResponse(responseCode = "404", description = "Product not found (handled by ResponseExceptionMapper)"), + @APIResponse(responseCode = "503", description = "Service unavailable (handled by ResponseExceptionMapper)") + }) + public Response getProductWithExceptionHandling( + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id) { + + LOGGER.info("REST: Demonstrating exception handling for product " + id); + try { + Product product = catalogService.getProduct(id); + + if (product != null) { + return Response.ok(Map.of( + "productId", id, + "product", product, + "note", "Product retrieved successfully - no exceptions thrown" + )).build(); + } else { + // Product not found (404 was handled by ResponseExceptionMapper) + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of( + "productId", id, + "error", "Product not found", + "note", "ProductNotFoundException was caught and handled by the service layer" + )) + .build(); + } + + } catch (ServiceUnavailableException e) { + // Unchecked exception from ResponseExceptionMapper + LOGGER.severe("Catalog service unavailable: " + e.getMessage()); + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of( + "productId", id, + "error", "Catalog service unavailable", + "statusCode", e.getStatusCode(), + "message", e.getMessage(), + "note", "ServiceUnavailableException (unchecked) was thrown by ResponseExceptionMapper" + )) + .build(); + + } catch (RuntimeException e) { + // Other runtime exceptions from ResponseExceptionMapper + LOGGER.severe("Unexpected error: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "productId", id, + "error", "Unexpected error", + "message", e.getMessage(), + "note", "RuntimeException was thrown by ResponseExceptionMapper" + )) + .build(); + } + } + + // ==================================================================================== + // RestClientBuilder (Programmatic Client Creation) Examples + // ==================================================================================== + + /** + * Checks product availability using RestClientBuilder (programmatic creation). + * + * Demonstrates: + * - Creating REST client programmatically with RestClientBuilder + * - Try-with-resources pattern for automatic resource cleanup + * - Using baseUri(String) method (MicroProfile Rest Client 4.0) + * - Configuring timeouts programmatically + * - When to prefer programmatic over CDI injection + * + * Example: GET /catalog/builder/products/1/check + * + * @param id Product ID to check + * @return JSON response indicating if product is available + */ + @GET + @Path("/builder/products/{id}/check") + @Operation( + summary = "Check product availability (RestClientBuilder)", + description = "Uses RestClientBuilder to programmatically create a REST client and check product availability. " + + "Demonstrates try-with-resources pattern and dynamic client configuration." + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Product availability check completed" + ) + }) + public Response checkProductWithBuilder( + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id) { + + LOGGER.info("Checking product availability using RestClientBuilder: " + id); + + try { + boolean available = builderService.isProductAvailable(id); + return Response.ok(Map.of( + "productId", id, + "available", available, + "method", "RestClientBuilder (programmatic)", + "note", available + ? "Product found using programmatically created client" + : "Product not found - ProductNotFoundException caught" + )).build(); + + } catch (Exception e) { + LOGGER.severe("Error checking product: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "productId", id, + "error", "Error checking product", + "message", e.getMessage() + )) + .build(); + } + } + + /** + * Gets all products using RestClientBuilder. + * + * Demonstrates: + * - Programmatic client creation for simple GET requests + * - Automatic resource cleanup with AutoCloseable interface + * - Comparison with CDI-injected client approach + * + * Example: GET /catalog/builder/products + * + * @return List of products retrieved using programmatic client + */ + @GET + @Path("/builder/products") + @Operation( + summary = "Get all products (RestClientBuilder)", + description = "Retrieves all products using a programmatically created REST client. " + + "Compare this with the CDI-injected version at /catalog/products" + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Products retrieved successfully" + ) + }) + public Response getAllProductsWithBuilder() { + LOGGER.info("Getting all products using RestClientBuilder"); + + try { + List products = builderService.getAllProducts(); + return Response.ok(Map.of( + "products", products, + "count", products.size(), + "method", "RestClientBuilder (programmatic)", + "note", "Client created with RestClientBuilder.newBuilder().baseUri(...).build()" + )).build(); + + } catch (Exception e) { + LOGGER.severe("Error getting products: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "error", "Error retrieving products", + "message", e.getMessage() + )) + .build(); + } + } + + /** + * Gets product with dynamic configuration from MicroProfile Config. + * + * Demonstrates: + * - Reading configuration at runtime with ConfigProvider + * - Dynamic client configuration based on external properties + * - Flexibility of programmatic client creation + * + * Example: GET /catalog/builder/products/1/dynamic + * + * @param id Product ID + * @return Product retrieved with dynamically configured client + */ + @GET + @Path("/builder/products/{id}/dynamic") + @Operation( + summary = "Get product with dynamic config (RestClientBuilder)", + description = "Demonstrates using RestClientBuilder with configuration loaded from MicroProfile Config at runtime. " + + "Shows how to read properties like base URL and timeouts dynamically." + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Product retrieved successfully" + ), + @APIResponse( + responseCode = "404", + description = "Product not found" + ) + }) + public Response getProductWithDynamicConfig( + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id) { + + LOGGER.info("Getting product with dynamic configuration: " + id); + + try { + Product product = builderService.getProductWithDynamicConfig(id); + + if (product != null) { + return Response.ok(Map.of( + "product", product, + "method", "RestClientBuilder with dynamic config", + "note", "Configuration loaded from microprofile-config.properties at runtime" + )).build(); + } else { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of( + "productId", id, + "error", "Product not found", + "method", "RestClientBuilder with dynamic config" + )) + .build(); + } + + } catch (Exception e) { + LOGGER.severe("Error getting product: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "productId", id, + "error", "Error retrieving product", + "message", e.getMessage() + )) + .build(); + } + } + + /** + * Gets product for specific environment. + * + * Demonstrates: + * - Environment-specific client configuration + * - Conditional configuration based on deployment environment + * - Different timeouts for different environments + * + * Example: GET /catalog/builder/products/1/env/dev + * + * @param environment Environment name (dev, staging, prod) + * @param id Product ID + * @return Product retrieved with environment-specific configuration + */ + @GET + @Path("/builder/products/{id}/env/{environment}") + @Operation( + summary = "Get product for environment (RestClientBuilder)", + description = "Demonstrates environment-specific REST client configuration. " + + "Different base URLs and timeouts are used based on the environment parameter." + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Product retrieved successfully" + ), + @APIResponse( + responseCode = "404", + description = "Product not found" + ) + }) + public Response getProductForEnvironment( + @Parameter(description = "Environment (dev, staging, prod)", required = true) + @PathParam("environment") String environment, + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id) { + + LOGGER.info(String.format("Getting product for environment %s: %s", environment, id)); + + try { + Product product = builderService.getProductForEnvironment(environment, id); + + if (product != null) { + return Response.ok(Map.of( + "product", product, + "environment", environment, + "method", "RestClientBuilder with environment-specific config", + "note", String.format("Client configured for %s environment", environment) + )).build(); + } else { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of( + "productId", id, + "environment", environment, + "error", "Product not found" + )) + .build(); + } + + } catch (Exception e) { + LOGGER.severe("Error getting product: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "productId", id, + "environment", environment, + "error", "Error retrieving product", + "message", e.getMessage() + )) + .build(); + } + } + + /** + * Checks availability of multiple products in batch. + * + * Demonstrates: + * - Creating multiple clients with different configurations + * - When programmatic creation is preferred for batch operations + * - Handling multiple requests efficiently + * - Shorter timeouts for batch processing + * + * Example: POST /catalog/builder/products/batch-check + * Body: {"productIds": [1, 2, 3, 4, 5]} + * + * @param request Request containing list of product IDs + * @return Batch check results + */ + @POST + @Path("/builder/products/batch-check") + @Operation( + summary = "Batch check product availability (RestClientBuilder)", + description = "Checks availability of multiple products using programmatically created clients. " + + "Demonstrates when programmatic creation is preferred over CDI injection." + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Batch check completed" + ) + }) + public Response batchCheckProducts(Map> request) { + List productIds = request.get("productIds"); + + if (productIds == null || productIds.isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of( + "error", "Missing or empty productIds array", + "example", Map.of("productIds", List.of(1, 2, 3)) + )) + .build(); + } + + LOGGER.info("Batch checking " + productIds.size() + " products"); + + try { + int availableCount = builderService.checkMultipleProducts(productIds); + + return Response.ok(Map.of( + "totalChecked", productIds.size(), + "availableCount", availableCount, + "unavailableCount", productIds.size() - availableCount, + "method", "RestClientBuilder (batch processing)", + "note", "Each product checked with a separate programmatically created client instance" + )).build(); + + } catch (Exception e) { + LOGGER.severe("Error in batch check: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "error", "Error in batch check", + "message", e.getMessage() + )) + .build(); + } + } + + // ==================================================================================== + // Custom Filters and Interceptors Examples + // ==================================================================================== + + /** + * Gets all products using REST client with custom filters. + * + * Demonstrates: + * - ClientRequestFilter for authentication (BearerTokenFilter) + * - ClientRequestFilter for correlation tracking (CorrelationIdFilter) + * - ClientRequestFilter for request logging (RequestLoggingFilter) + * - ClientResponseFilter for response logging (ResponseLoggingFilter) + * - Filter execution order using @Priority + * + * Compare the server logs for this endpoint with /catalog/products to see + * the comprehensive logging and tracing provided by the filters. + * + * Example: GET /catalog/filtered/products + * + * @return List of products with full filter logging + */ + @GET + @Path("/filtered/products") + @Operation( + summary = "Get all products with filters", + description = "Retrieves products using a REST client with custom filters registered. " + + "Check server logs to see filter execution: BearerTokenFilter (auth), " + + "CorrelationIdFilter (tracing), RequestLoggingFilter and ResponseLoggingFilter." + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Products retrieved with comprehensive filter logging" + ) + }) + public Response getAllProductsWithFilters() { + LOGGER.info("Getting all products with custom filters"); + + try { + List products = filteredCatalogService.getAllProducts(); + return Response.ok(Map.of( + "products", products, + "count", products.size(), + "method", "REST Client with Custom Filters", + "filters", List.of( + "BearerTokenFilter (Priority 1000 - AUTHENTICATION)", + "CorrelationIdFilter (Priority 100)", + "RequestLoggingFilter (Priority 300)", + "ResponseLoggingFilter (Priority 300)" + ), + "note", "Check server logs for detailed filter execution traces" + )).build(); + + } catch (Exception e) { + LOGGER.severe("Error getting products with filters: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "error", "Error retrieving products", + "message", e.getMessage() + )) + .build(); + } + } + + /** + * Gets a specific product using REST client with custom filters. + * + * Demonstrates: + * - Filter execution with path parameters + * - Correlation ID propagation + * - Request/response logging for specific resource retrieval + * - Authentication header injection + * + * Example: GET /catalog/filtered/products/1 + * + * @param id Product ID + * @return Product with comprehensive filter logging + */ + @GET + @Path("/filtered/products/{id}") + @Operation( + summary = "Get product by ID with filters", + description = "Retrieves a specific product using filtered REST client. " + + "Demonstrates filter execution with path parameters and exception handling." + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Product retrieved successfully" + ), + @APIResponse( + responseCode = "404", + description = "Product not found" + ) + }) + public Response getProductWithFilters( + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id) { + + LOGGER.info("Getting product with custom filters: " + id); + + try { + Product product = filteredCatalogService.getProduct(id); + return Response.ok(Map.of( + "product", product, + "method", "REST Client with Custom Filters", + "filterChain", "BearerToken → CorrelationId → RequestLogging → [HTTP] → ResponseLogging", + "note", "Check server logs for X-Correlation-ID and detailed request/response traces" + )).build(); + + } catch (ProductNotFoundException e) { + LOGGER.warning("Product not found with filters: " + id); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of( + "productId", id, + "error", "Product not found", + "note", "404 response was logged by ResponseLoggingFilter before exception was thrown" + )) + .build(); + + } catch (Exception e) { + LOGGER.severe("Error getting product with filters: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "productId", id, + "error", "Error retrieving product", + "message", e.getMessage() + )) + .build(); + } + } + + /** + * Checks product availability using filtered REST client. + * + * Demonstrates: + * - How filters handle error responses (404) + * - Response logging includes error status codes + * - Correlation IDs help trace failed requests + * + * Example: GET /catalog/filtered/products/999999/available + * + * @param id Product ID to check + * @return Availability status with filter logging + */ + @GET + @Path("/filtered/products/{id}/available") + @Operation( + summary = "Check product availability with filters", + description = "Checks if a product is available using filtered REST client. " + + "Demonstrates how filters log both successful and error responses." + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Availability check completed" + ) + }) + public Response checkAvailabilityWithFilters( + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id) { + + LOGGER.info("Checking availability with filters: " + id); + + try { + boolean available = filteredCatalogService.isProductAvailable(id); + return Response.ok(Map.of( + "productId", id, + "available", available, + "method", "REST Client with Custom Filters", + "note", available + ? "Product found - check logs for filter execution" + : "Product not found - ResponseLoggingFilter logged 404 before returning false" + )).build(); + + } catch (Exception e) { + LOGGER.severe("Error checking availability with filters: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "productId", id, + "error", "Error checking availability", + "message", e.getMessage() + )) + .build(); + } + } + + /** + * Comparison endpoint showing differences between filtered and non-filtered clients. + * + * Demonstrates: + * - Side-by-side comparison of filtered vs non-filtered REST clients + * - Impact of filters on observability + * - When to use filters vs. plain clients + * + * Example: GET /catalog/compare/1 + * + * @param id Product ID to retrieve with both clients + * @return Comparison results + */ + @GET + @Path("/compare/{id}") + @Operation( + summary = "Compare filtered vs non-filtered clients", + description = "Retrieves the same product using both filtered and non-filtered clients. " + + "Check server logs to compare the logging output." + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Comparison completed" + ) + }) + public Response compareClients( + @Parameter(description = "Product ID", required = true) + @PathParam("id") Long id) { + + LOGGER.info("========== Starting Client Comparison =========="); + + try { + // Call with non-filtered client + LOGGER.info("--- Calling NON-FILTERED client ---"); + Product productNoFilter = catalogService.getProduct(id); + + // Small delay for log clarity + Thread.sleep(100); + + // Call with filtered client + LOGGER.info("--- Calling FILTERED client ---"); + Product productWithFilter = filteredCatalogService.getProduct(id); + + LOGGER.info("========== Client Comparison Complete =========="); + + return Response.ok(Map.of( + "productId", id, + "productName", productNoFilter.getName(), + "comparison", Map.of( + "noFilters", "Minimal logging - only business logic logs", + "withFilters", "Comprehensive logging - authentication, tracing, request/response details" + ), + "filterBenefits", List.of( + "Authentication without changing business code", + "Distributed tracing with correlation IDs", + "Complete request/response visibility", + "Easier debugging and monitoring", + "Cross-cutting concerns separated from business logic" + ), + "note", "Review server logs to see the dramatic difference in observability" + )).build(); + + } catch (ProductNotFoundException e) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of( + "productId", id, + "error", "Product not found" + )) + .build(); + + } catch (Exception e) { + LOGGER.severe("Error in comparison: " + e.getMessage()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of( + "productId", id, + "error", "Error during comparison", + "message", e.getMessage() + )) + .build(); + } + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/FilteredProductCatalogService.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/FilteredProductCatalogService.java new file mode 100644 index 00000000..6e3ef16a --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/FilteredProductCatalogService.java @@ -0,0 +1,124 @@ +package io.microprofile.tutorial.store.payment.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import io.microprofile.tutorial.store.payment.client.ProductClientWithFilters; +import io.microprofile.tutorial.store.payment.dto.product.Product; +import io.microprofile.tutorial.store.payment.exception.ProductNotFoundException; + +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Service demonstrating MicroProfile Rest Client with custom filters. + * + * This service injects ProductClientWithFilters which has multiple filters registered: + * - BearerTokenFilter: Adds authentication headers + * - CorrelationIdFilter: Adds distributed tracing headers + * - RequestLoggingFilter: Logs outgoing requests + * - ResponseLoggingFilter: Logs incoming responses + * + * Compare the logs from this service with ProductCatalogService (no filters) + * to see the difference in observability and debugging capabilities. + * + * The filters provide: + * - Authentication without modifying business logic + * - Distributed tracing across microservices + * - Comprehensive request/response logging + * - Header propagation patterns + */ +@ApplicationScoped +public class FilteredProductCatalogService { + + private static final Logger LOGGER = Logger.getLogger(FilteredProductCatalogService.class.getName()); + + @Inject + @RestClient + ProductClientWithFilters filteredClient; + + /** + * Gets all products using filtered REST client. + * + * Watch the logs to see filter execution: + * 1. BearerTokenFilter adds Authorization header + * 2. CorrelationIdFilter adds X-Correlation-ID + * 3. RequestLoggingFilter logs complete request + * 4. HTTP request sent + * 5. HTTP response received + * 6. ResponseLoggingFilter logs complete response + * + * @return List of all products + */ + public List getAllProducts() { + LOGGER.info("Getting all products using filtered REST client"); + + try { + List products = filteredClient.getAllProducts(); + LOGGER.info("Successfully retrieved " + products.size() + " products with filters"); + return products; + + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error retrieving products with filters: " + e.getMessage(), e); + throw new RuntimeException("Failed to retrieve products", e); + } + } + + /** + * Gets a specific product by ID using filtered REST client. + * + * Demonstrates filter execution with path parameters. + * Check logs to see how filters handle parameterized requests. + * + * @param productId The product ID + * @return The product + * @throws ProductNotFoundException if product not found + */ + public Product getProduct(Long productId) throws ProductNotFoundException { + LOGGER.info("Getting product with filters: " + productId); + + try { + Product product = filteredClient.getProductById(productId); + LOGGER.info("Successfully retrieved product with filters: " + product.getName()); + return product; + + } catch (ProductNotFoundException e) { + LOGGER.log(Level.WARNING, "Product not found with filters: " + productId, e); + throw e; + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error retrieving product with filters: " + e.getMessage(), e); + throw new RuntimeException("Failed to retrieve product", e); + } + } + + /** + * Checks if a product is available using filtered REST client. + * + * Demonstrates how filters work with exception handling. + * The 404 response will still be logged by ResponseLoggingFilter + * before the exception is thrown. + * + * @param productId The product ID to check + * @return true if product is available, false otherwise + */ + public boolean isProductAvailable(Long productId) { + LOGGER.info("Checking product availability with filters: " + productId); + + try { + Product product = filteredClient.getProductById(productId); + boolean available = (product != null); + LOGGER.info("Product " + productId + " availability with filters: " + available); + return available; + + } catch (ProductNotFoundException e) { + LOGGER.fine("Product not found with filters (expected for availability check): " + productId); + return false; + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Error checking availability with filters: " + e.getMessage(), e); + return false; + } + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentRequestHandler.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentRequestHandler.java new file mode 100644 index 00000000..ebd57b6c --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentRequestHandler.java @@ -0,0 +1,27 @@ +package io.microprofile.tutorial.store.payment.service; + +import io.microprofile.tutorial.store.payment.entity.Order; +import io.microprofile.tutorial.store.payment.entity.OrderStatus; +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Outgoing; + +import java.util.logging.Logger; + +/** + * Reactive messaging handler that runs in the payment service and marks + * incoming orders as paid for downstream processing. + */ +@ApplicationScoped +public class PaymentRequestHandler { + + private static final Logger LOGGER = Logger.getLogger(PaymentRequestHandler.class.getName()); + + @Incoming("order-created") + @Outgoing("payment-authorized") + public Order processPayment(Order order) { + LOGGER.info(() -> "Processing payment for order " + order.getOrderId()); + order.setStatus(OrderStatus.PAID); + return order; + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java new file mode 100644 index 00000000..7e7c6d2d --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/PaymentService.java @@ -0,0 +1,46 @@ +package io.microprofile.tutorial.store.payment.service; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; + + +@RequestScoped +@Path("/authorize") +public class PaymentService { + + @Inject + @ConfigProperty(name = "payment.gateway.endpoint") + private String endpoint; + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Process payment", description = "Process payment using the payment gateway API") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Payment processed successfully"), + @APIResponse(responseCode = "400", description = "Invalid input data"), + @APIResponse(responseCode = "500", description = "Internal server error") + }) + public Response processPayment() { + + // Example logic to call the payment gateway API + System.out.println("Calling payment gateway API at: " + endpoint); + // Assuming a successful payment operation for demonstration purposes + // Actual implementation would involve calling the payment gateway and handling the response + + // Dummy response for successful payment processing + String result = "{\"status\":\"success\", \"message\":\"Payment processed successfully.\"}"; + return Response.ok(result, MediaType.APPLICATION_JSON).build(); + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductCatalogService.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductCatalogService.java new file mode 100644 index 00000000..5c7579c1 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductCatalogService.java @@ -0,0 +1,154 @@ +package io.microprofile.tutorial.store.payment.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import io.microprofile.tutorial.store.payment.client.ProductClient; +import io.microprofile.tutorial.store.payment.dto.product.Product; +import io.microprofile.tutorial.store.payment.exception.ProductNotFoundException; +import io.microprofile.tutorial.store.payment.exception.ServiceUnavailableException; + +import java.util.List; +import java.util.logging.Logger; + +/** + * Service demonstrating CDI injection of MicroProfile Rest Client. + * + * This class showcases: + * - @Inject annotation for dependency injection + * - @RestClient qualifier (mandatory in MicroProfile Rest Client 4.0) + * - @ApplicationScoped for efficient singleton pattern + * - Type-safe REST client usage without manual instantiation + * - Automatic configuration via MicroProfile Config + * + * The ProductClient is automatically configured using properties from + * microprofile-config.properties (catalog-service/mp-rest/*) + */ +@ApplicationScoped +public class ProductCatalogService { + + private static final Logger LOGGER = Logger.getLogger(ProductCatalogService.class.getName()); + + /** + * MicroProfile Rest Client injected via CDI. + * The @RestClient qualifier is mandatory in MicroProfile Rest Client 4.0. + */ + @Inject + @RestClient + private ProductClient productClient; + + /** + * Retrieves a product by ID from the catalog service. + * + * Demonstrates: + * - Simple method call on injected REST client + * - Handling checked exception from ResponseExceptionMapper + * - Automatic JSON deserialization to Product object + * - Custom exception handling for 404 responses + * + * @param productId The product ID to retrieve + * @return The product or null if not found + */ + public Product getProduct(Long productId) { + LOGGER.info("Fetching product with ID: " + productId); + try { + Product product = productClient.getProductById(productId); + LOGGER.info("Successfully retrieved product: " + product.getName()); + return product; + } catch (ProductNotFoundException e) { + // Checked exception from ResponseExceptionMapper for 404 responses + LOGGER.warning("Product not found (404): " + e.getMessage()); + return null; + } catch (ServiceUnavailableException e) { + // Unchecked exception from ResponseExceptionMapper for 503 responses + LOGGER.severe("Catalog service unavailable (503): " + e.getMessage()); + throw e; // Re-throw to caller + } catch (RuntimeException e) { + // Other runtime exceptions from ResponseExceptionMapper + LOGGER.severe("Unexpected error: " + e.getMessage()); + throw e; + } + } + + /** + * Checks if a product is available for payment processing. + * + * Demonstrates: + * - Exception handling with REST clients + * - Distinguishing between ProductNotFoundException and other errors + * - Boolean logic based on client response + * + * @param productId The product ID to check + * @return true if product exists and is available, false otherwise + */ + public boolean isProductAvailable(Long productId) { + try { + Product product = productClient.getProductById(productId); + boolean available = product != null && product.getPrice() != null && product.getPrice() > 0; + LOGGER.info("Product " + productId + " availability: " + available); + return available; + } catch (ProductNotFoundException e) { + // Product doesn't exist - return false, don't propagate exception + LOGGER.info("Product " + productId + " not found (404)"); + return false; + } catch (Exception e) { + LOGGER.warning("Error checking product " + productId + " availability: " + e.getMessage()); + return false; + } + } + + /** + * Retrieves all products from the catalog service. + * + * Demonstrates: + * - REST client method returning collections + * - Handling list responses + * + * @return List of all products + */ + public List getAllProducts() { + LOGGER.info("Fetching all products from catalog service"); + try { + List products = productClient.getAllProducts(); + LOGGER.info("Retrieved " + products.size() + " products"); + return products; + } catch (Exception e) { + LOGGER.severe("Failed to retrieve products: " + e.getMessage()); + return List.of(); // Return empty list on error + } + } + + /** + * Validates if a product price matches the expected amount. + * + * Demonstrates: + * - Business logic using REST client data + * - Validation scenarios in payment processing + * - Exception handling for not found products + * + * @param productId The product ID + * @param expectedPrice The expected price + * @return true if prices match, false otherwise + */ + public boolean validateProductPrice(Long productId, Double expectedPrice) { + try { + Product product = productClient.getProductById(productId); + if (product != null && product.getPrice() != null) { + boolean priceMatches = product.getPrice().equals(expectedPrice); + if (!priceMatches) { + LOGGER.warning(String.format( + "Price mismatch for product %d: expected %.2f, actual %.2f", + productId, expectedPrice, product.getPrice() + )); + } + return priceMatches; + } + return false; + } catch (Exception e) { + LOGGER.severe("Failed to validate product price: " + e.getMessage()); + return false; + } + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductClientBuilderService.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductClientBuilderService.java new file mode 100644 index 00000000..f0ddb680 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductClientBuilderService.java @@ -0,0 +1,221 @@ +package io.microprofile.tutorial.store.payment.service; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; +import org.eclipse.microprofile.rest.client.RestClientBuilder; + +import io.microprofile.tutorial.store.payment.client.ProductClient; +import io.microprofile.tutorial.store.payment.dto.product.Product; +import io.microprofile.tutorial.store.payment.exception.ProductNotFoundException; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Service demonstrating programmatic REST client creation using RestClientBuilder. + * + * This class showcases: + * - Creating REST clients programmatically without CDI injection + * - Using the RestClientBuilder fluent API + * - Try-with-resources pattern for automatic resource cleanup + * - Dynamic configuration from MicroProfile Config + * - Configuring timeouts and connection parameters + * - When to use programmatic vs CDI-based client creation + * + * Use RestClientBuilder when: + * - CDI is unavailable + * - Client configuration must be determined dynamically at runtime + * - Multiple endpoints need different configurations + * - Creating clients in utility methods or batch jobs + * + * Always use try-with-resources to ensure proper resource cleanup. + */ +@ApplicationScoped +public class ProductClientBuilderService { + + private static final Logger LOGGER = Logger.getLogger(ProductClientBuilderService.class.getName()); + + /** + * Checks if a product is available using a programmatically created client. + * Demonstrates the basic RestClientBuilder usage with try-with-resources. + * + * @param productId The product ID to check + * @return true if product exists, false otherwise + */ + public boolean isProductAvailable(Long productId) { + LOGGER.info("Checking product availability using RestClientBuilder: " + productId); + + // MicroProfile Rest Client 4.0 introduces baseUri(String) for convenience + // No need to call URI.create() explicitly + try (ProductClient productClient = RestClientBuilder.newBuilder() + .baseUri("http://localhost:5050/catalog/api") + .connectTimeout(3, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build(ProductClient.class)) { + + Product product = productClient.getProductById(productId); + LOGGER.info("Product found: " + product.getName()); + return product != null; + + } catch (ProductNotFoundException e) { + LOGGER.log(Level.WARNING, "Product not found with ID: " + productId, e); + return false; + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error checking product availability: " + e.getMessage(), e); + return false; + } + } + + /** + * Retrieves all products using a programmatically created client. + * Demonstrates try-with-resources ensuring proper client closure. + * + * @return List of all products, or empty list if error occurs + */ + public List getAllProducts() { + LOGGER.info("Retrieving all products using RestClientBuilder"); + + try (ProductClient productClient = RestClientBuilder.newBuilder() + .baseUri("http://localhost:5050/catalog/api") + .connectTimeout(3, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build(ProductClient.class)) { + + List products = productClient.getAllProducts(); + LOGGER.info("Retrieved " + products.size() + " products"); + return products; + + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error retrieving products: " + e.getMessage(), e); + return List.of(); // Return empty list on error + } + } + + /** + * Creates a REST client with dynamic configuration from MicroProfile Config. + * Demonstrates reading configuration at runtime for flexible deployment. + * + * Configuration properties: + * - catalog-service/mp-rest/url - Base URL of the catalog service + * - catalog-service/mp-rest/connectTimeout - Connection timeout in milliseconds + * - catalog-service/mp-rest/readTimeout - Read timeout in milliseconds + * + * @param productId The product ID to retrieve + * @return The product, or null if not found + */ + public Product getProductWithDynamicConfig(Long productId) { + LOGGER.info("Getting product with dynamic configuration: " + productId); + + Config config = ConfigProvider.getConfig(); + + // Read configuration from MicroProfile Config + String baseUrl = config.getValue("catalog-service/mp-rest/url", String.class); + int connectTimeout = config.getOptionalValue("catalog-service/mp-rest/connectTimeout", Integer.class) + .orElse(3000); + int readTimeout = config.getOptionalValue("catalog-service/mp-rest/readTimeout", Integer.class) + .orElse(5000); + + LOGGER.info(String.format("Using dynamic config - URL: %s, ConnectTimeout: %dms, ReadTimeout: %dms", + baseUrl, connectTimeout, readTimeout)); + + try (ProductClient productClient = RestClientBuilder.newBuilder() + .baseUri(baseUrl) + .connectTimeout(connectTimeout, TimeUnit.MILLISECONDS) + .readTimeout(readTimeout, TimeUnit.MILLISECONDS) + .build(ProductClient.class)) { + + Product product = productClient.getProductById(productId); + LOGGER.info("Product retrieved with dynamic config: " + product.getName()); + return product; + + } catch (ProductNotFoundException e) { + LOGGER.log(Level.WARNING, "Product not found: " + productId, e); + return null; + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error retrieving product: " + e.getMessage(), e); + return null; + } + } + + /** + * Creates a REST client with custom configuration based on environment. + * Demonstrates conditional configuration for different deployment scenarios. + * + * @param environment Environment name (e.g., "dev", "staging", "prod") + * @param productId The product ID to retrieve + * @return The product, or null if not found + */ + public Product getProductForEnvironment(String environment, Long productId) { + LOGGER.info("Getting product for environment: " + environment); + + // Determine base URL based on environment + String baseUrl = switch (environment.toLowerCase()) { + case "dev" -> "http://localhost:5050/catalog/api"; + case "staging" -> "http://staging-catalog:8080/catalog/api"; + case "prod" -> "http://catalog-service:8080/catalog/api"; + default -> "http://localhost:5050/catalog/api"; + }; + + // Use shorter timeouts for production + long timeout = "prod".equals(environment) ? 2000 : 5000; + + LOGGER.info(String.format("Using environment config - URL: %s, Timeout: %dms", baseUrl, timeout)); + + try (ProductClient productClient = RestClientBuilder.newBuilder() + .baseUri(baseUrl) + .connectTimeout(timeout, TimeUnit.MILLISECONDS) + .readTimeout(timeout, TimeUnit.MILLISECONDS) + .followRedirects(true) // Enable redirect following + .build(ProductClient.class)) { + + return productClient.getProductById(productId); + + } catch (ProductNotFoundException e) { + LOGGER.log(Level.WARNING, "Product not found: " + productId, e); + return null; + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error retrieving product: " + e.getMessage(), e); + return null; + } + } + + /** + * Demonstrates creating multiple clients with different configurations. + * Shows when programmatic creation is preferred over CDI injection. + * + * @param productIds List of product IDs to check + * @return Number of available products + */ + public int checkMultipleProducts(List productIds) { + LOGGER.info("Checking availability for " + productIds.size() + " products"); + + int availableCount = 0; + + for (Long productId : productIds) { + // Create a new client for each product check + // This demonstrates the flexibility of programmatic creation + try (ProductClient productClient = RestClientBuilder.newBuilder() + .baseUri("http://localhost:5050/catalog/api") + .connectTimeout(1, TimeUnit.SECONDS) // Shorter timeout for batch operations + .readTimeout(2, TimeUnit.SECONDS) + .build(ProductClient.class)) { + + productClient.getProductById(productId); + availableCount++; + + } catch (ProductNotFoundException e) { + LOGGER.fine("Product not found: " + productId); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Error checking product: " + productId, e); + } + } + + LOGGER.info(String.format("Found %d out of %d products available", + availableCount, productIds.size())); + + return availableCount; + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductIntegrationService.java b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductIntegrationService.java new file mode 100644 index 00000000..ba677ea3 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/ProductIntegrationService.java @@ -0,0 +1,148 @@ +package io.microprofile.tutorial.store.payment.service; + +import io.microprofile.tutorial.store.payment.client.ProductJakartaRestClient; +import io.microprofile.tutorial.store.payment.dto.product.Product; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Service for integrating with the Product/Catalog service. + * Provides business logic for product validation and retrieval in the context of payment processing. + */ +@ApplicationScoped +public class ProductIntegrationService { + + private static final Logger LOGGER = Logger.getLogger(ProductIntegrationService.class.getName()); + + @Inject + @ConfigProperty(name = "catalog.service.url", defaultValue = "http://localhost:5050/catalog/api/products") + private String catalogServiceUrl; + + /** + * Validates if a product is suitable for payment processing. + * + * @param productId The product ID to validate + * @return true if the product is valid for payment, false otherwise + */ + public boolean validateProductForPayment(Long productId) { + LOGGER.info("Validating product for payment: " + productId); + + try { + Product product = getProductDetails(productId); + if (product == null) { + LOGGER.warning("Product not found: " + productId); + return false; + } + + // Basic validation: product must have a valid price and name + boolean isValid = product.price != null && + product.price > 0.0 && + product.name != null && + !product.name.trim().isEmpty(); + + LOGGER.info("Product " + productId + " validation result: " + isValid); + return isValid; + + } catch (Exception e) { + LOGGER.severe("Error validating product " + productId + ": " + e.getMessage()); + return false; + } + } + + /** + * Gets detailed information about a specific product. + * + * @param productId The product ID + * @return Product details or null if not found + */ + public Product getProductDetails(Long productId) { + LOGGER.info("Fetching product details for ID: " + productId); + + try { + Product[] allProducts = ProductJakartaRestClient.getProductsWithJsonp(catalogServiceUrl); + + if (allProducts != null) { + for (Product product : allProducts) { + if (product.id.equals(productId)) { + LOGGER.info("Found product: " + product.name + " (ID: " + productId + ")"); + return product; + } + } + } + + LOGGER.warning("Product not found with ID: " + productId); + return null; + + } catch (Exception e) { + LOGGER.severe("Error fetching product details for ID " + productId + ": " + e.getMessage()); + return null; + } + } + + /** + * Gets products within a specified price range. + * + * @param minPrice Minimum price (inclusive) + * @param maxPrice Maximum price (inclusive) + * @return List of products within the price range + */ + public List getProductsByPriceRange(double minPrice, double maxPrice) { + LOGGER.info("Fetching products in price range: " + minPrice + " - " + maxPrice); + + try { + Product[] allProducts = ProductJakartaRestClient.getProductsWithJsonp(catalogServiceUrl); + + if (allProducts == null) { + LOGGER.warning("No products returned from catalog service"); + return new ArrayList<>(); + } + + List filteredProducts = Arrays.stream(allProducts) + .filter(product -> product.price != null) + .filter(product -> product.price >= minPrice) + .filter(product -> product.price <= maxPrice) + .collect(Collectors.toList()); + + LOGGER.info("Found " + filteredProducts.size() + " products in price range"); + return filteredProducts; + + } catch (Exception e) { + LOGGER.severe("Error fetching products by price range: " + e.getMessage()); + return new ArrayList<>(); + } + } + + /** + * Gets all available products. + * + * @return Array of all products + */ + public Product[] getAllProducts() { + LOGGER.info("Fetching all products"); + + try { + Product[] products = ProductJakartaRestClient.getProductsWithJsonp(catalogServiceUrl); + + if (products != null) { + LOGGER.info("Retrieved " + products.length + " products"); + } else { + LOGGER.warning("No products returned from catalog service"); + products = new Product[0]; + } + + return products; + + } catch (Exception e) { + LOGGER.severe("Error fetching all products: " + e.getMessage()); + return new Product[0]; + } + } +} diff --git a/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http new file mode 100644 index 00000000..98ae2e52 --- /dev/null +++ b/code/chapter13/payment/src/main/java/io/microprofile/tutorial/store/payment/service/payment.http @@ -0,0 +1,9 @@ +POST https://orange-zebra-r745vp6rjcxp67-9080.app.github.dev/payment/authorize + +{ + "cardNumber": "4111111111111111", + "cardHolderName": "John Doe", + "expiryDate": "12/25", + "securityCode": "123", + "amount": 100.00 +} \ No newline at end of file diff --git a/code/chapter13/payment/src/main/resources/META-INF/microprofile-config.properties b/code/chapter13/payment/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 00000000..6d74363c --- /dev/null +++ b/code/chapter13/payment/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,48 @@ +# Minimal setup: no external broker. +# PaymentRequestHandler creates order message on the "order-created" channel, +# then processes it and marks it as PAID for downstream flow. +# The order-created and payment-authorized channels are internal — no connector needed. + +# Kafka configuration for production: +mp.messaging.connector.liberty-kafka.bootstrap.servers=localhost:9092 +mp.messaging.incoming.order-created.connector=liberty-kafka +mp.messaging.incoming.order-created.topic=order-created-topic +mp.messaging.incoming.order-created.group.id=payment-service-group +mp.messaging.outgoing.payment-authorized.connector=liberty-kafka +mp.messaging.outgoing.payment-authorized.topic=payment-authorized-topic + +# microprofile-config.properties +mp.openapi.scan.disable=false +product.maintenanceMode=false + +# Product Service Configuration +payment.gateway.endpoint=https://api.paymentgateway.com/v1 + +# Catalog Service Configuration for ProductClientJson +catalog.service.url=http://localhost:5050/catalog/api/products +catalog.service.fallback.url=http://localhost:6050/products +catalog.service.timeout=5000 + +# MicroProfile Rest Client Configuration for ProductClient (catalog-service) +# Base URL - required property for REST client +catalog-service/mp-rest/url=http://localhost:5050/catalog/api +# Connection timeout in milliseconds +catalog-service/mp-rest/connectTimeout=3000 +# Read timeout in milliseconds +catalog-service/mp-rest/readTimeout=5000 +# CDI scope for the injected client +catalog-service/mp-rest/scope=jakarta.enterprise.context.ApplicationScoped +# Follow HTTP redirects +catalog-service/mp-rest/followRedirects=true + +# MicroProfile Rest Client Configuration for ProductClientWithFilters (catalog-service-filtered) +# This client demonstrates custom filters and interceptors +catalog-service-filtered/mp-rest/url=http://localhost:5050/catalog/api +catalog-service-filtered/mp-rest/connectTimeout=3000 +catalog-service-filtered/mp-rest/readTimeout=5000 +catalog-service-filtered/mp-rest/scope=jakarta.enterprise.context.ApplicationScoped +catalog-service-filtered/mp-rest/followRedirects=true + +# Optional Bearer token for authentication (used by BearerTokenFilter) +# Uncomment and set your token to enable authentication +# catalog-service.bearer.token=your-secret-bearer-token-here diff --git a/code/chapter13/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource b/code/chapter13/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource new file mode 100644 index 00000000..98707178 --- /dev/null +++ b/code/chapter13/payment/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource @@ -0,0 +1 @@ +io.microprofile.tutorial.store.payment.config.PaymentServiceConfigSource \ No newline at end of file diff --git a/code/chapter13/payment/src/main/webapp/WEB-INF/beans.xml b/code/chapter13/payment/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 00000000..d61f0180 --- /dev/null +++ b/code/chapter13/payment/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,8 @@ + + + diff --git a/code/chapter13/payment/src/main/webapp/index.html b/code/chapter13/payment/src/main/webapp/index.html new file mode 100644 index 00000000..33086f26 --- /dev/null +++ b/code/chapter13/payment/src/main/webapp/index.html @@ -0,0 +1,140 @@ + + + + + + Payment Service - MicroProfile Config Demo + + + +
+

Payment Service

+

MicroProfile Config Demo with Custom ConfigSource

+
+ +
+
+

About this Service

+

The Payment Service demonstrates MicroProfile Config integration with custom ConfigSource implementation.

+

It provides endpoints for managing payment configuration and processing payments using dynamic configuration.

+

Key Features:

+
    +
  • Custom MicroProfile ConfigSource with ordinal 600 (highest priority)
  • +
  • Dynamic configuration updates via REST API
  • +
  • Payment gateway endpoint configuration
  • +
  • Real-time configuration access for payment processing
  • +
+
+ +
+

API Endpoints

+
    +
  • GET /api/payment-config - Get current payment configuration
  • +
  • POST /api/payment-config - Update payment configuration property
  • +
  • POST /api/authorize - Process payment authorization
  • +
  • POST /api/payment-config/process-example - Example payment processing with config
  • +
+
+ +
+

Configuration Management

+

This service implements a custom MicroProfile ConfigSource that allows dynamic configuration updates:

+
    +
  • Configuration Priority: Custom ConfigSource (600) > System Properties (400) > Environment Variables (300) > microprofile-config.properties (100)
  • +
  • Current Properties: payment.gateway.endpoint
  • +
  • Update Method: POST to /api/payment-config with {"key": "payment.property.name", "value": "new-value"}
  • +
+
+ + +
+ +
+

MicroProfile Config Demo | Payment Service

+

Powered by Open Liberty & MicroProfile Config 3.0

+
+ + diff --git a/code/chapter13/payment/src/main/webapp/index.jsp b/code/chapter13/payment/src/main/webapp/index.jsp new file mode 100644 index 00000000..d5de5cb2 --- /dev/null +++ b/code/chapter13/payment/src/main/webapp/index.jsp @@ -0,0 +1,12 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + + Redirecting... + + +

Redirecting to the Payment Service homepage...

+ + diff --git a/code/chapter13/reactive-messaging-hello/README.md b/code/chapter13/reactive-messaging-hello/README.md new file mode 100644 index 00000000..e6a7048f --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/README.md @@ -0,0 +1,336 @@ +# Open Liberty Reactive Messaging Hello World + +A comprehensive demonstration of MicroProfile Reactive Messaging 3.0 on Open Liberty, showcasing message publishing, consuming, and processing patterns. + +## Features + +- ✅ **Message Publishing** - Send messages via REST API or programmatically +- ✅ **Message Consumption** - Receive and process messages asynchronously +- ✅ **Stream Processing** - Transform messages using @Incoming/@Outgoing +- ✅ **Multiple Patterns** - Emitter-based and Publisher-based messaging +- ✅ **REST API** - Interact with the messaging system via HTTP endpoints +- ✅ **Kafka Ready** - Pre-configured for Apache Kafka (with in-memory fallback) + +## Architecture + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ +│ REST API │─────▶│ HelloPublisher │─────▶│ Message │ +│ │ │ (Emitter/Stream) │ │ Channel │ +└─────────────┘ └──────────────────┘ └──────┬──────┘ + │ + ┌──────────────────┐ ┌──────▼──────┐ + │ HelloConsumer │◀─────│ Message │ + │ (@Incoming) │ │ Channel │ + └──────────────────┘ └─────────────┘ +``` + +## Prerequisites + +- Java 17 or later +- Maven 3.8+ +- Open Liberty 23.0.0.3+ (will be downloaded automatically) +- Optional: Apache Kafka (for production use) + +## Project Structure + +``` +reactive-messaging-hello/ +├── pom.xml +├── src/ +│ ├── main/ +│ │ ├── java/com/example/reactive/ +│ │ │ ├── ReactiveMessagingApplication.java +│ │ │ ├── model/ +│ │ │ │ └── HelloMessage.java +│ │ │ ├── publisher/ +│ │ │ │ └── HelloPublisher.java +│ │ │ ├── consumer/ +│ │ │ │ └── HelloConsumer.java +│ │ │ └── resource/ +│ │ │ └── HelloResource.java +│ │ ├── resources/ +│ │ │ └── META-INF/ +│ │ │ └── microprofile-config.properties +│ │ ├── liberty/config/ +│ │ │ └── server.xml +│ │ └── webapp/ +│ │ └── WEB-INF/ +│ │ └── beans.xml +└── README.md +``` + +## Quick Start + +### 1. Build the Application + +```bash +mvn clean package +``` + +### 2. Start Open Liberty + +```bash +mvn liberty:run +``` + +The server will start on http://localhost:9080 + +### 3. Test the Application + +**Welcome Endpoint:** +```bash +curl http://localhost:9080/api/hello +``` + +**Publish a Message:** +```bash +curl -X POST http://localhost:9080/api/hello/publish \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello Reactive Messaging!"}' +``` + +**View Received Messages:** +```bash +curl http://localhost:9080/api/hello/messages +``` + +**Health Check:** +```bash +curl http://localhost:9080/api/hello/health +``` + +**Clear Messages:** +```bash +curl -X DELETE http://localhost:9080/api/hello/messages +``` + +## Configuration + +### Message Channels + +The application uses two main channels configured in `microprofile-config.properties`: + +- **`hello-out`** - Outgoing channel for publishing messages +- **`hello-in`** - Incoming channel for consuming messages + +### Connector Options + +#### Option 1: In-Memory Connector (Default for Testing) + +Uncomment in `microprofile-config.properties`: +```properties +mp.messaging.outgoing.hello-out.connector=smallrye-in-memory +mp.messaging.incoming.hello-in.connector=smallrye-in-memory +``` + +#### Option 2: Apache Kafka (Production) + +Use the default Kafka configuration: +```properties +mp.messaging.outgoing.hello-out.connector=liberty-kafka +mp.messaging.outgoing.hello-out.topic=hello-topic + +mp.messaging.incoming.hello-in.connector=liberty-kafka +mp.messaging.incoming.hello-in.topic=hello-topic +mp.messaging.incoming.hello-in.group.id=hello-consumer-group +``` + +**Start Kafka locally:** +```bash +# Using Docker +docker run -d --name kafka \ + -p 9092:9092 \ + apache/kafka:latest +``` + +## Key Concepts Demonstrated + +### 1. Emitter Pattern + +```java +@Inject +@Channel("hello-out") +Emitter helloEmitter; + +public void publishMessage(String message) { + helloEmitter.send(message); +} +``` + +### 2. Simple Consumer + +```java +@Incoming("hello-in") +public void consumeHelloMessage(String message) { + LOGGER.info("Received: " + message); +} +``` + +### 3. Consumer with Acknowledgment + +```java +@Incoming("hello-in") +public CompletionStage consumeHelloMessageWithAck(Message message) { + String payload = message.getPayload(); + // Process message + return message.ack(); +} +``` + +### 4. Stream Processing + +```java +@Incoming("processing-in") +@Outgoing("processing-out") +public String processMessage(String incoming) { + return incoming.toUpperCase(); +} +``` + +### 5. Publisher Pattern + +```java +@Outgoing("hello-out") +public Publisher generatePeriodicMessages() { + return PublisherBuilder.generate(() -> "Message #" + counter++) + .buildRs(); +} +``` + +## Testing Scenarios + +### Scenario 1: Simple Message Flow + +1. Publish a message via REST API +2. Consumer receives and logs it +3. Verify in received messages list + +```bash +# Publish +curl -X POST http://localhost:9080/api/hello/publish \ + -H "Content-Type: application/json" \ + -d '{"message": "Test Message 1"}' + +# Verify +curl http://localhost:9080/api/hello/messages +``` + +### Scenario 2: Bulk Publishing + +```bash +for i in {1..5}; do + curl -X POST http://localhost:9080/api/hello/publish \ + -H "Content-Type: application/json" \ + -d "{\"message\": \"Bulk Message $i\"}" +done + +curl http://localhost:9080/api/hello/messages +``` + +### Scenario 3: Automatic Stream Generation + +Uncomment the `@Outgoing` annotation in `HelloPublisher.generatePeriodicMessages()` to enable automatic message generation every 10 seconds. + +## Server Logs + +Watch the logs to see message flow: + +```bash +tail -f target/liberty/wlp/usr/servers/reactiveMessagingServer/logs/messages.log +``` + +Expected output: +``` +[INFO] Publishing message via Emitter: Hello Reactive Messaging! +[INFO] ✓ Received message: Hello Reactive Messaging! +[INFO] Processing: Hello Reactive Messaging! +``` + +## Deployment + +### Package for Deployment + +```bash +mvn clean package +``` + +The WAR file will be created at `target/reactive-messaging-hello.war` + +### Deploy to Existing Open Liberty + +1. Copy the WAR to Liberty's `dropins` folder: +```bash +cp target/reactive-messaging-hello.war $LIBERTY_HOME/usr/servers/defaultServer/dropins/ +``` + +2. Update `server.xml` to include the required features + +## Troubleshooting + +### Issue: Messages not being received + +**Solution:** Check that both publisher and consumer are using the same channel names and connector configuration. + +### Issue: Kafka connection errors + +**Solution:** +- Verify Kafka is running: `docker ps` +- Check bootstrap.servers configuration +- Or switch to in-memory connector for testing + +### Issue: CDI injection failures + +**Solution:** Ensure `beans.xml` exists in `WEB-INF/` and `bean-discovery-mode="all"` + +## Advanced Topics + +### Custom Message Types + +Extend `HelloMessage` model and use JSON serialization: + +```java +@Incoming("hello-in") +public void consume(HelloMessage message) { + // Process structured message +} +``` + +### Error Handling + +Add error handling to consumer: + +```java +@Incoming("hello-in") +public CompletionStage consume(Message message) { + try { + processMessage(message.getPayload()); + return message.ack(); + } catch (Exception e) { + return message.nack(e); + } +} +``` + +### Dead Letter Queue + +Configure DLQ in `microprofile-config.properties`: + +```properties +mp.messaging.incoming.hello-in.failure-strategy=dead-letter-queue +mp.messaging.incoming.hello-in.dead-letter-queue.topic=hello-dlq +``` + +## References + +- [MicroProfile Reactive Messaging Specification](https://download.eclipse.org/microprofile/microprofile-reactive-messaging-3.0/microprofile-reactive-messaging-spec-3.0.html) +- [Open Liberty Reactive Messaging](https://openliberty.io/docs/latest/reactive-messaging.html) +- [SmallRye Reactive Messaging](https://smallrye.io/smallrye-reactive-messaging/) + +## License + +Apache License 2.0 + +## Author + +Created as a demonstration of MicroProfile Reactive Messaging on Open Liberty. diff --git a/code/chapter13/reactive-messaging-hello/pom.xml b/code/chapter13/reactive-messaging-hello/pom.xml new file mode 100644 index 00000000..df363848 --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + com.example + reactive-messaging-hello + 1.0-SNAPSHOT + war + + + 21 + 21 + UTF-8 + 9080 + 9443 + + + + + + org.eclipse.microprofile + microprofile + 7.1 + pom + provided + + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + org.eclipse.microprofile.reactive.messaging + microprofile-reactive-messaging-api + 3.0.1 + provided + + + + + io.smallrye.reactive + smallrye-reactive-messaging-in-memory + 4.12.0 + test + + + + org.eclipse.microprofile.reactive-streams-operators + microprofile-reactive-streams-operators-api + 3.0 + provided + + + + org.apache.kafka + kafka-clients + 3.9.0 + + + + + ${project.artifactId} + + + org.apache.maven.plugins + maven-war-plugin + 3.3.2 + + + + io.openliberty.tools + liberty-maven-plugin + 3.10 + + reactiveMessagingServer + + + + + diff --git a/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/ReactiveMessagingApplication.java b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/ReactiveMessagingApplication.java new file mode 100644 index 00000000..2cf3a075 --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/ReactiveMessagingApplication.java @@ -0,0 +1,13 @@ +package com.example.reactive; + +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; + +/** + * JAX-RS Application configuration + */ +@ApplicationPath("/api") +public class ReactiveMessagingApplication extends Application { + // No additional configuration needed + // All JAX-RS resources will be automatically discovered +} diff --git a/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/consumer/HelloConsumer.java b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/consumer/HelloConsumer.java new file mode 100644 index 00000000..98e347b1 --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/consumer/HelloConsumer.java @@ -0,0 +1,75 @@ +package com.example.reactive.consumer; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Message; + +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Logger; + +/** + * Message consumer that receives and processes hello messages + */ +@ApplicationScoped +public class HelloConsumer { + + private static final Logger LOGGER = Logger.getLogger(HelloConsumer.class.getName()); + + // Thread-safe list to store received messages + private final CopyOnWriteArrayList receivedMessages = new CopyOnWriteArrayList<>(); + + /** + * Simple message consumption - payload only + */ + @Incoming("hello-in") + public void consumeHelloMessage(String message) { + LOGGER.info("✓ Received message: " + message); + receivedMessages.add(message); + + // Simulate some processing + processMessage(message); + } + + /** + * Advanced message consumption with acknowledgment + * Uncomment to use this instead of the simple consumer above + */ + /* + @Incoming("hello-in") + public CompletionStage consumeHelloMessageWithAck(Message message) { + String payload = message.getPayload(); + LOGGER.info("✓ Received message with ack: " + payload); + receivedMessages.add(payload); + + processMessage(payload); + + // Acknowledge the message + return message.ack(); + } + */ + + private void processMessage(String message) { + // Add your business logic here + LOGGER.info("Processing: " + message); + + if (message.contains("error")) { + LOGGER.warning("Message contains error keyword!"); + } + } + + /** + * Get all received messages (for testing/monitoring) + */ + public CopyOnWriteArrayList getReceivedMessages() { + return receivedMessages; + } + + /** + * Clear received messages + */ + public void clearMessages() { + receivedMessages.clear(); + LOGGER.info("Cleared all received messages"); + } +} diff --git a/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/model/HelloMessage.java b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/model/HelloMessage.java new file mode 100644 index 00000000..6a11aec2 --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/model/HelloMessage.java @@ -0,0 +1,55 @@ +package com.example.reactive.model; + +import java.time.LocalDateTime; + +/** + * Simple message model for demonstration + */ +public class HelloMessage { + private String content; + private LocalDateTime timestamp; + private String sender; + + public HelloMessage() { + this.timestamp = LocalDateTime.now(); + } + + public HelloMessage(String content, String sender) { + this.content = content; + this.sender = sender; + this.timestamp = LocalDateTime.now(); + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public String getSender() { + return sender; + } + + public void setSender(String sender) { + this.sender = sender; + } + + @Override + public String toString() { + return "HelloMessage{" + + "content='" + content + '\'' + + ", timestamp=" + timestamp + + ", sender='" + sender + '\'' + + '}'; + } +} diff --git a/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/publisher/HelloPublisher.java b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/publisher/HelloPublisher.java new file mode 100644 index 00000000..8073ddf0 --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/publisher/HelloPublisher.java @@ -0,0 +1,58 @@ +package com.example.reactive.publisher; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; +import org.eclipse.microprofile.reactive.streams.operators.ReactiveStreams; +import org.reactivestreams.Publisher; + +import jakarta.inject.Inject; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; + +/** + * Message publisher that generates hello messages periodically + */ +@ApplicationScoped +public class HelloPublisher { + + private static final Logger LOGGER = Logger.getLogger(HelloPublisher.class.getName()); + private final AtomicInteger counter = new AtomicInteger(0); + + @Inject + @Channel("hello-out") + Emitter helloEmitter; + + /** + * Programmatic message publishing via Emitter + */ + public void publishMessage(String message) { + LOGGER.info("Publishing message via Emitter: " + message); + helloEmitter.send(message); + } + + /** + * Stream-based publishing - generates messages every 10 seconds + * Uncomment the @Outgoing annotation to enable automatic publishing + */ + // @Outgoing("hello-out") + public Publisher generatePeriodicMessages() { + return ReactiveStreams.generate(() -> { + int count = counter.incrementAndGet(); + String message = "Hello from Open Liberty #" + count; + LOGGER.info("Generated periodic message: " + message); + return message; + }) + .buildRs(); + } + + /** + * Example message transformation helper. + * Kept as a plain method so it does not require extra reactive channel wiring. + */ + public String processMessage(String incoming) { + String processed = incoming.toUpperCase() + " [PROCESSED]"; + LOGGER.info("Processing: " + incoming + " -> " + processed); + return processed; + } +} diff --git a/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/resource/HelloResource.java b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/resource/HelloResource.java new file mode 100644 index 00000000..9fa85c27 --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/src/main/java/com/example/reactive/resource/HelloResource.java @@ -0,0 +1,111 @@ +package com.example.reactive.resource; + +import com.example.reactive.consumer.HelloConsumer; +import com.example.reactive.publisher.HelloPublisher; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * REST API for interacting with the Reactive Messaging system + */ +@ApplicationScoped +@Path("/hello") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class HelloResource { + + @Inject + private HelloPublisher publisher; + + @Inject + private HelloConsumer consumer; + + /** + * Publish a custom message + * POST /hello/publish + * Body: { "message": "Your message here" } + */ + @POST + @Path("/publish") + public Response publishMessage(Map payload) { + String message = payload.getOrDefault("message", "Hello from REST API"); + publisher.publishMessage(message); + + Map response = new HashMap<>(); + response.put("status", "published"); + response.put("message", message); + + return Response.ok(response).build(); + } + + /** + * Get all received messages + * GET /hello/messages + */ + @GET + @Path("/messages") + public Response getReceivedMessages() { + List messages = consumer.getReceivedMessages(); + + Map response = new HashMap<>(); + response.put("count", messages.size()); + response.put("messages", messages); + + return Response.ok(response).build(); + } + + /** + * Clear all received messages + * DELETE /hello/messages + */ + @DELETE + @Path("/messages") + public Response clearMessages() { + consumer.clearMessages(); + + Map response = new HashMap<>(); + response.put("status", "cleared"); + + return Response.ok(response).build(); + } + + /** + * Health check endpoint + * GET /hello/health + */ + @GET + @Path("/health") + public Response health() { + Map health = new HashMap<>(); + health.put("status", "UP"); + health.put("service", "Reactive Messaging Demo"); + health.put("messagesReceived", consumer.getReceivedMessages().size()); + + return Response.ok(health).build(); + } + + /** + * Welcome endpoint + * GET /hello + */ + @GET + public Response welcome() { + Map info = new HashMap<>(); + info.put("application", "Open Liberty Reactive Messaging Demo"); + info.put("endpoints", Map.of( + "POST /hello/publish", "Publish a message", + "GET /hello/messages", "Get received messages", + "DELETE /hello/messages", "Clear received messages", + "GET /hello/health", "Health check" + )); + + return Response.ok(info).build(); + } +} diff --git a/code/chapter13/reactive-messaging-hello/src/main/resources/META-INF/microprofile-config.properties b/code/chapter13/reactive-messaging-hello/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 00000000..c80bc05f --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,20 @@ +# Message Publisher Configuration +mp.messaging.outgoing.hello-out.connector=liberty-kafka +mp.messaging.outgoing.hello-out.topic=hello-topic + +# Message Consumer Configuration +mp.messaging.incoming.hello-in.connector=liberty-kafka +mp.messaging.incoming.hello-in.topic=hello-topic +mp.messaging.incoming.hello-in.group.id=hello-consumer-group + +# Alternative: In-Memory Connector (for testing without Kafka) +# Uncomment these and comment out the kafka connectors above +# mp.messaging.outgoing.hello-out.connector=smallrye-in-memory +# mp.messaging.incoming.hello-in.connector=smallrye-in-memory + +# Kafka connection properties (adjust for your environment) +mp.messaging.connector.liberty-kafka.bootstrap.servers=localhost:9092 +mp.messaging.connector.liberty-kafka.key.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.connector.liberty-kafka.value.serializer=org.apache.kafka.common.serialization.StringSerializer +mp.messaging.connector.liberty-kafka.key.deserializer=org.apache.kafka.common.serialization.StringDeserializer +mp.messaging.connector.liberty-kafka.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer diff --git a/code/chapter13/reactive-messaging-hello/src/main/webapp/WEB-INF/beans.xml b/code/chapter13/reactive-messaging-hello/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 00000000..35fd4317 --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,8 @@ + + + diff --git a/code/chapter13/reactive-messaging-hello/src/main/webapp/index.html b/code/chapter13/reactive-messaging-hello/src/main/webapp/index.html new file mode 100644 index 00000000..01f5f55e --- /dev/null +++ b/code/chapter13/reactive-messaging-hello/src/main/webapp/index.html @@ -0,0 +1,329 @@ + + + + + + Open Liberty Reactive Messaging Demo + + + +
+
+

🚀 Open Liberty Reactive Messaging

+

MicroProfile Reactive Messaging 3.0 Demo

+
+ +
+
+ +
+

📤 Publish Message

+
+ + +
+
+ +
+

📊 Statistics

+
+
+
0
+
Messages Received
+
+
+
+
Server Status
+
+
+
+ +
+

📬 Received Messages

+
+ + +
+
+
Click "Refresh" to load messages
+
+
+
+
+ + + + diff --git a/code/chapter13/simple-producer-consumer/README.adoc b/code/chapter13/simple-producer-consumer/README.adoc new file mode 100644 index 00000000..eaa2492d --- /dev/null +++ b/code/chapter13/simple-producer-consumer/README.adoc @@ -0,0 +1,92 @@ += Simple Producer/Consumer Demo + +A minimal Open Liberty project that demonstrates MicroProfile Reactive Messaging with a producer using `@Outgoing` and a consumer using `@Incoming`. + +This project shows a linear three-stage message pipeline inside one application. A producer emits a single message to the `greetings-in` channel, a processor transforms it and forwards it to the `greetings-out` channel, and a consumer logs the final message to the server console. + +== How it works + +[source,text] +---- ++---------------------------------------+ +| SimpleMessageBean | +| | +| @Outgoing("greetings-in") | +| produce(): PublisherBuilder | ++------------------+--------------------+ + | + v + +----------------+ + | greetings-in | + | channel | + +-------+--------+ + | + v ++------------------------------------------+ +| SimpleMessageBean | +| | +| @Incoming("greetings-in") | +| @Outgoing("greetings-out") | +| process(String): String (toUpperCase) | ++------------------+-----------------------+ + | + v + +----------------+ + | greetings-out | + | channel | + +-------+--------+ + | + v ++---------------------------------------+ +| SimpleMessageBean | +| | +| @Incoming("greetings-out") | +| consume(String message) | ++---------------------------------------+ +---- + +== Project structure + +[source,text] +---- +src/ +└── main/ + ├── java/ + │ └── com/example/simple/ + │ └── SimpleMessageBean.java # producer, processor, and consumer methods + ├── liberty/ + │ └── config/ + │ └── server.xml # Open Liberty runtime configuration + ├── resources/ + │ └── META-INF/ + │ └── microprofile-config.properties # messaging configuration notes + └── webapp/ + └── WEB-INF/ + └── beans.xml # CDI bean discovery +---- + +== Run the project + +[source,bash] +---- +mvn clean package +mvn liberty:run +---- + +By default, this demo starts on port `9085` to avoid conflicts with other tutorial projects that commonly use `9080`. + +When the application starts, check the server console output. You should see log entries similar to the following: + +[source,text] +---- +[INFO] [INFO ] Sending message: Hello, MicroProfile Reactive Messaging +[INFO] [INFO ] Processing message: Hello, MicroProfile Reactive Messaging +[INFO] [INFO ] Received message: HELLO, MICROPROFILE REACTIVE MESSAGING +---- + +== What it shows + +* `@Outgoing("greetings-in")` on `produce()` returns a `PublisherBuilder` wrapping a single message. Using `PublisherBuilder` ensures the stream completes after one item, preventing an infinite generator loop. +* `@Incoming("greetings-in")` + `@Outgoing("greetings-out")` on `process()` demonstrates the processor pattern — consuming from one channel, transforming the payload to uppercase, and publishing to a different channel. +* `@Incoming("greetings-out")` on `consume()` is the terminal consumer that logs the final transformed message. +* Using distinct channel names (`greetings-in`, `greetings-out`) avoids a loopback anti-pattern where the same bean both produces and consumes on the same channel. diff --git a/code/chapter13/simple-producer-consumer/pom.xml b/code/chapter13/simple-producer-consumer/pom.xml new file mode 100644 index 00000000..95c68765 --- /dev/null +++ b/code/chapter13/simple-producer-consumer/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + com.example + simple-producer-consumer + 1.0-SNAPSHOT + war + + + 21 + 21 + UTF-8 + 9085 + 9443 + + + + + org.eclipse.microprofile + microprofile + 7.1 + pom + provided + + + + jakarta.platform + jakarta.jakartaee-api + 10.0.0 + provided + + + + org.eclipse.microprofile.reactive.messaging + microprofile-reactive-messaging-api + 3.0.1 + provided + + + + org.eclipse.microprofile.reactive-streams-operators + microprofile-reactive-streams-operators-api + 3.0 + provided + + + + + ${project.artifactId} + + + org.apache.maven.plugins + maven-war-plugin + 3.3.2 + + + + io.openliberty.tools + liberty-maven-plugin + 3.10 + + simpleProducerConsumerServer + + + + + diff --git a/code/chapter13/simple-producer-consumer/src/main/java/com/example/simple/SimpleMessageBean.java b/code/chapter13/simple-producer-consumer/src/main/java/com/example/simple/SimpleMessageBean.java new file mode 100644 index 00000000..c420ff3f --- /dev/null +++ b/code/chapter13/simple-producer-consumer/src/main/java/com/example/simple/SimpleMessageBean.java @@ -0,0 +1,34 @@ +package com.example.simple; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Incoming; +import org.eclipse.microprofile.reactive.messaging.Outgoing; +import org.eclipse.microprofile.reactive.streams.operators.PublisherBuilder; +import org.eclipse.microprofile.reactive.streams.operators.ReactiveStreams; + +import java.util.logging.Logger; + +@ApplicationScoped +public class SimpleMessageBean { + + private static final Logger LOGGER = Logger.getLogger(SimpleMessageBean.class.getName()); + + @Outgoing("greetings-in") + public PublisherBuilder produce() { + String message = "Hello, MicroProfile Reactive Messaging"; + LOGGER.info("Sending message: " + message); + return ReactiveStreams.of(message); + } + + @Incoming("greetings-in") + @Outgoing("greetings-out") + public String process(String message) { + LOGGER.info("Processing message: " + message); + return message.toUpperCase(); + } + + @Incoming("greetings-out") + public void consume(String message) { + LOGGER.info("Received message: " + message); + } +} diff --git a/code/chapter13/simple-producer-consumer/src/main/resources/META-INF/microprofile-config.properties b/code/chapter13/simple-producer-consumer/src/main/resources/META-INF/microprofile-config.properties new file mode 100644 index 00000000..323fe465 --- /dev/null +++ b/code/chapter13/simple-producer-consumer/src/main/resources/META-INF/microprofile-config.properties @@ -0,0 +1,2 @@ +# No external connector is required for this example. +# The producer and consumer are wired internally through the "greetings" channel. diff --git a/code/chapter13/simple-producer-consumer/src/main/webapp/WEB-INF/beans.xml b/code/chapter13/simple-producer-consumer/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 00000000..b708636e --- /dev/null +++ b/code/chapter13/simple-producer-consumer/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,7 @@ + + +